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(crate) mod auth;
27pub(crate) mod confluence;
28pub(crate) mod error;
29pub(crate) mod git_browser;
30pub(crate) mod git_webhook;
31pub(crate) mod integrations;
32
33use std::{
34 collections::{HashMap, VecDeque},
35 fmt::Write,
36 fs,
37 net::{IpAddr, SocketAddr},
38 path::{Path, PathBuf},
39 process::Stdio,
40 sync::{Arc, OnceLock},
41 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
42};
43
44use anyhow::{Context, Result};
45use askama::Template;
46use axum::{
47 body::Body,
48 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
49 http::{header, HeaderValue, Request, StatusCode},
50 middleware::{self, Next},
51 response::{Html, IntoResponse, Response},
52 routing::{get, post},
53 Json, Router,
54};
55use serde::{Deserialize, Serialize};
56use tokio::sync::Mutex;
57use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
58
59use sloc_config::{
60 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
61 MixedLinePolicy,
62};
63use sloc_git::ScheduleStore;
64
65#[derive(Clone)]
66pub(crate) struct CspNonce(pub(crate) String);
67
68static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
69static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
70
71use sloc_core::{
72 analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
73 CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
74 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
75};
76use sloc_report::{
77 render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
78 write_pdf_from_run, ReportDeltaContext,
79};
80const MAX_CONCURRENT_ANALYSES: usize = 4;
81
82#[cfg(target_os = "windows")]
90#[allow(clippy::upper_case_acronyms)]
91#[allow(dead_code)]
92mod win_dialog_focus {
93 #[cfg(feature = "native-dialog")]
94 use std::mem::size_of;
95
96 type HWND = *mut core::ffi::c_void;
97 type DWORD = u32;
98 type UINT = u32;
99 type BOOL = i32;
100
101 #[cfg(feature = "native-dialog")]
103 #[repr(C)]
104 #[allow(non_snake_case)]
105 struct FLASHWINFO {
106 cbSize: UINT,
107 hwnd: HWND,
108 dwFlags: DWORD,
109 uCount: UINT,
110 dwTimeout: DWORD,
111 }
112
113 #[cfg(feature = "native-dialog")]
114 const FLASHW_ALL: DWORD = 0x3;
115 #[cfg(feature = "native-dialog")]
116 const FLASHW_TIMERNOFG: DWORD = 0xC;
117
118 #[link(name = "user32")]
119 extern "system" {
120 fn GetForegroundWindow() -> HWND;
121 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
122 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
123 fn BringWindowToTop(hWnd: HWND) -> BOOL;
124 fn SetWindowPos(
125 hWnd: HWND,
126 hWndAfter: HWND,
127 x: i32,
128 y: i32,
129 cx: i32,
130 cy: i32,
131 flags: UINT,
132 ) -> BOOL;
133 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
134 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
135 #[cfg(feature = "native-dialog")]
136 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
137 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
138 fn FindWindowExW(
139 hWndParent: HWND,
140 hWndChildAfter: HWND,
141 lpszClass: *const u16,
142 lpszWindow: *const u16,
143 ) -> HWND;
144 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
148 }
149
150 #[link(name = "kernel32")]
151 extern "system" {
152 fn GetCurrentThreadId() -> DWORD;
153 }
154
155 #[link(name = "shell32")]
156 extern "system" {
157 fn ShellExecuteW(
162 hwnd: HWND,
163 lpOperation: *const u16,
164 lpFile: *const u16,
165 lpParameters: *const u16,
166 lpDirectory: *const u16,
167 nShowCmd: i32,
168 ) -> isize; }
170
171 #[cfg(feature = "native-dialog")]
176 pub fn attach_to_foreground() -> DWORD {
177 unsafe {
178 let fg_hwnd = GetForegroundWindow();
179 if fg_hwnd.is_null() {
180 return 0;
181 }
182 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
183 let my_tid = GetCurrentThreadId();
184 if fg_tid == my_tid {
185 return 0;
186 }
187 AttachThreadInput(my_tid, fg_tid, 1);
188 fg_tid
189 }
190 }
191
192 #[cfg(feature = "native-dialog")]
194 pub fn detach_from_foreground(fg_tid: DWORD) {
195 if fg_tid == 0 {
196 return;
197 }
198 unsafe {
199 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
200 }
201 }
202
203 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
204 let mut existing = std::collections::HashSet::new();
205 let mut prev: HWND = core::ptr::null_mut();
206 loop {
207 let w = FindWindowExW(
208 core::ptr::null_mut(),
209 prev,
210 class_w.as_ptr(),
211 core::ptr::null(),
212 );
213 if w.is_null() {
214 break;
215 }
216 existing.insert(w as usize);
217 prev = w;
218 }
219 existing
220 }
221
222 unsafe fn find_new_explorer_hwnd(
223 class_w: &[u16],
224 existing: &std::collections::HashSet<usize>,
225 ) -> Option<HWND> {
226 let mut prev: HWND = core::ptr::null_mut();
227 loop {
228 let w = FindWindowExW(
229 core::ptr::null_mut(),
230 prev,
231 class_w.as_ptr(),
232 core::ptr::null(),
233 );
234 if w.is_null() {
235 return None;
236 }
237 if !existing.contains(&(w as usize)) {
238 return Some(w);
239 }
240 prev = w;
241 }
242 }
243
244 unsafe fn bring_to_front(hwnd: HWND) {
245 let my_tid = GetCurrentThreadId();
253 let fg_hwnd = GetForegroundWindow();
254 let fg_tid = if fg_hwnd.is_null() {
255 0
256 } else {
257 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
258 };
259 let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
260
261 ShowWindow(hwnd, 9);
264 BringWindowToTop(hwnd);
265 SetForegroundWindow(hwnd);
266 SwitchToThisWindow(hwnd, 1);
269
270 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
275 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
276
277 if attached {
278 AttachThreadInput(my_tid, fg_tid, 0);
279 }
280 }
281
282 pub fn open_folder_foreground(path: std::path::PathBuf) {
289 std::thread::spawn(move || {
290 use std::os::windows::ffi::OsStrExt;
291
292 let op: Vec<u16> = "explore\0".encode_utf16().collect();
293 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
294 path_w.push(0);
295 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
296
297 unsafe {
298 let existing = snapshot_explorer_hwnds(&class_w);
301 let fg_hwnd = GetForegroundWindow();
302 ShellExecuteW(
304 fg_hwnd,
305 op.as_ptr(),
306 path_w.as_ptr(),
307 core::ptr::null(),
308 core::ptr::null(),
309 1,
310 );
311
312 for _ in 0..40 {
316 std::thread::sleep(std::time::Duration::from_millis(75));
317 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
318 bring_to_front(w);
319 return;
320 }
321 }
322
323 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
326 if !w.is_null() {
327 bring_to_front(w);
328 }
329 }
330 });
331 }
332
333 #[cfg(feature = "native-dialog")]
337 pub fn flash_dialog_when_ready(title: String) {
338 std::thread::spawn(move || {
339 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
340 for _ in 0..40 {
341 std::thread::sleep(std::time::Duration::from_millis(80));
342 unsafe {
343 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
344 if !hwnd.is_null() {
345 SetForegroundWindow(hwnd);
346 BringWindowToTop(hwnd);
347 #[allow(non_snake_case)]
348 FlashWindowEx(&FLASHWINFO {
349 #[allow(clippy::cast_possible_truncation)]
352 cbSize: size_of::<FLASHWINFO>() as UINT,
353 hwnd,
354 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
355 uCount: 3,
356 dwTimeout: 0,
357 });
358 break;
359 }
360 }
361 }
362 });
363 }
364}
365
366pub(crate) struct IpRateLimiter {
369 window: Duration,
370 max_requests: usize,
371 pub(crate) auth_lockout_threshold: u32,
372 auth_lockout_window: Duration,
373 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
374 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
375}
376
377impl IpRateLimiter {
378 pub(crate) fn new(
379 window: Duration,
380 max_requests: usize,
381 auth_lockout_threshold: u32,
382 auth_lockout_window: Duration,
383 ) -> Self {
384 Self {
385 window,
386 max_requests,
387 auth_lockout_threshold,
388 auth_lockout_window,
389 state: std::sync::Mutex::new(HashMap::new()),
390 auth_failures: std::sync::Mutex::new(HashMap::new()),
391 }
392 }
393
394 #[allow(clippy::significant_drop_tightening)]
397 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
398 let now = Instant::now();
399 let cutoff = now.checked_sub(self.window).unwrap_or(now);
400 let mut state = self
401 .state
402 .lock()
403 .unwrap_or_else(std::sync::PoisonError::into_inner);
404 if state.len() > 10_000 {
405 state.retain(|_, bucket| {
406 while bucket.front().is_some_and(|t| *t <= cutoff) {
407 bucket.pop_front();
408 }
409 !bucket.is_empty()
410 });
411 }
412 let bucket = state.entry(ip).or_default();
413 while bucket.front().is_some_and(|t| *t <= cutoff) {
414 bucket.pop_front();
415 }
416 if bucket.len() >= self.max_requests {
417 false
418 } else {
419 bucket.push_back(now);
420 true
421 }
422 }
423
424 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
425 let now = Instant::now();
426 let mut map = self
427 .auth_failures
428 .lock()
429 .unwrap_or_else(std::sync::PoisonError::into_inner);
430 map.entry(ip)
431 .and_modify(|e| {
432 e.0 += 1;
433 e.1 = now;
434 })
435 .or_insert_with(|| (1, now));
436 }
437
438 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
439 let mut map = self
440 .auth_failures
441 .lock()
442 .unwrap_or_else(std::sync::PoisonError::into_inner);
443 let expired = map
444 .get(&ip)
445 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
446 if expired {
447 map.remove(&ip);
448 return false;
449 }
450 map.get(&ip)
451 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
452 }
453
454 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
455 let map = self
456 .auth_failures
457 .lock()
458 .unwrap_or_else(std::sync::PoisonError::into_inner);
459 map.get(&ip).map_or(0, |e| {
460 self.auth_lockout_window
461 .checked_sub(e.1.elapsed())
462 .map_or(0, |r| r.as_secs())
463 })
464 }
465
466 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
467 tokio::spawn(async move {
468 let mut interval = tokio::time::interval(Duration::from_mins(1));
469 interval.tick().await; loop {
471 interval.tick().await;
472 let now = Instant::now();
473 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
474 {
475 let mut state = limiter
476 .state
477 .lock()
478 .unwrap_or_else(std::sync::PoisonError::into_inner);
479 state.retain(|_, bucket| {
480 while bucket.front().is_some_and(|t| *t <= cutoff) {
481 bucket.pop_front();
482 }
483 !bucket.is_empty()
484 });
485 }
486 {
487 let mut auth = limiter
488 .auth_failures
489 .lock()
490 .unwrap_or_else(std::sync::PoisonError::into_inner);
491 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
492 }
493 }
494 });
495 }
496}
497
498fn spawn_upload_staging_cleanup() {
502 tokio::spawn(async move {
503 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
504 .ok()
505 .and_then(|v| v.parse().ok())
506 .unwrap_or(4);
507 let ttl_secs = ttl_hours * 3600;
508 let mut interval = tokio::time::interval(Duration::from_hours(1));
509 interval.tick().await; loop {
511 interval.tick().await;
512 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
513 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
514 continue;
515 };
516 while let Ok(Some(entry)) = dir.next_entry().await {
517 let path = entry.path();
518 let age_secs = tokio::fs::metadata(&path)
519 .await
520 .ok()
521 .and_then(|m| m.modified().ok())
522 .and_then(|t| t.elapsed().ok())
523 .map_or(0, |d| d.as_secs());
524 if age_secs > ttl_secs {
525 tracing::debug!(
526 event = "upload_staging_cleanup",
527 path = %path.display(),
528 age_secs,
529 "removing stale upload staging directory"
530 );
531 let _ = tokio::fs::remove_dir_all(&path).await;
532 }
533 }
534 }
535 });
536}
537
538#[derive(Clone, Debug, Default)]
540struct RunResultContext {
541 prev_entry: Option<RegistryEntry>,
542 prev_scan_count: usize,
543 project_path: String,
544 cocomo_mode: String,
546 complexity_alert: u32,
548 #[allow(dead_code)]
550 exclude_duplicates: bool,
551}
552
553#[derive(Clone)]
555enum AsyncRunState {
556 Running {
557 started_at: std::time::Instant,
558 cancel_token: Arc<std::sync::atomic::AtomicBool>,
559 phase: Arc<std::sync::Mutex<String>>,
560 files_done: Arc<std::sync::atomic::AtomicUsize>,
561 files_total: Arc<std::sync::atomic::AtomicUsize>,
562 },
563 Complete {
565 run_id: String,
566 },
567 Failed {
568 message: String,
569 },
570 Cancelled,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize)]
576struct ScanProfile {
577 id: String,
578 name: String,
579 created_at: String,
580 params: serde_json::Value,
582}
583
584#[derive(Debug, Clone, Default, Serialize, Deserialize)]
585struct ScanProfileStore {
586 profiles: Vec<ScanProfile>,
587}
588
589impl ScanProfileStore {
590 fn load(path: &std::path::Path) -> Self {
591 fs::read_to_string(path)
592 .ok()
593 .and_then(|s| serde_json::from_str(&s).ok())
594 .unwrap_or_default()
595 }
596
597 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
598 if let Some(parent) = path.parent() {
599 fs::create_dir_all(parent)?;
600 }
601 let json = serde_json::to_string_pretty(self)?;
602 fs::write(path, json)?;
603 Ok(())
604 }
605}
606
607#[derive(Clone)]
608pub(crate) struct AppState {
609 pub(crate) base_config: AppConfig,
610 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
611 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
612 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
613 pub(crate) registry_path: PathBuf,
614 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
615 pub(crate) server_mode: bool,
616 pub(crate) allow_unauthenticated: bool,
620 pub(crate) tls_enabled: bool,
621 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
622 pub(crate) rate_limiter: Arc<IpRateLimiter>,
623 pub(crate) trust_proxy: bool,
624 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
627 pub(crate) git_clones_dir: PathBuf,
629 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
631 pub(crate) schedules_path: PathBuf,
632 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
634 pub(crate) scan_profiles_path: PathBuf,
635 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
636 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
638 pub(crate) confluence_path: PathBuf,
639 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
641 pub(crate) watched_dirs_path: PathBuf,
642 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
644 pub(crate) cleanup_policy_path: PathBuf,
645 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
647}
648
649type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
650
651#[derive(Clone, Debug)]
654pub(crate) struct RunArtifacts {
655 output_dir: PathBuf,
656 html_path: Option<PathBuf>,
657 pdf_path: Option<PathBuf>,
658 json_path: Option<PathBuf>,
659 csv_path: Option<PathBuf>,
660 xlsx_path: Option<PathBuf>,
661 scan_config_path: Option<PathBuf>,
662 report_title: String,
663 result_context: RunResultContext,
664}
665
666#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
668 let protected = Router::new()
669 .route("/", get(splash))
670 .route("/scan-setup", get(scan_setup_handler))
671 .route("/scan", get(index))
672 .route("/analyze", post(analyze_handler))
673 .route("/preview", get(preview_handler))
674 .route("/api/suggest-coverage", get(api_suggest_coverage))
675 .route("/pick-directory", get(pick_directory_handler))
676 .route("/open-path", get(open_path_handler))
677 .route("/pick-file", get(pick_file_handler))
678 .route(
679 "/api/upload-directory",
680 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
681 )
682 .route(
683 "/api/upload-file",
684 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
685 )
686 .route(
687 "/api/upload-tarball",
688 post(upload_tarball_handler)
691 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
692 )
693 .route("/locate-report", post(locate_report_handler))
694 .route("/locate-reports-dir", post(locate_reports_dir_handler))
695 .route("/relocate-scan", post(relocate_scan_handler))
696 .route("/watched-dirs/add", post(add_watched_dir_handler))
697 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
698 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
699 .route("/view-reports", get(history_handler))
700 .route("/compare-scans", get(compare_select_handler))
701 .route("/compare", get(compare_handler))
702 .route("/multi-compare", get(multi_compare_handler))
703 .route("/images/{folder}/{file}", get(image_handler))
704 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
705 .route("/api/metrics/latest", get(api_metrics_latest_handler))
706 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
707 .route("/api/metrics/history", get(api_metrics_history_handler))
708 .route("/api/metrics/churn", get(api_metrics_churn_handler))
709 .route(
710 "/api/metrics/submodules",
711 get(api_metrics_submodules_handler),
712 )
713 .route("/api/ingest", post(api_ingest_handler))
714 .route("/api/project-history", get(project_history_handler))
715 .route("/trend-reports", get(trend_report_handler))
716 .route("/test-metrics", get(test_metrics_handler))
717 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
718 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
719 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
720 .route("/runs/result/{run_id}", get(async_run_result_handler))
721 .route("/embed/summary", get(embed_handler))
722 .route("/git-browser", get(git_browser::git_browser_handler))
724 .route("/api/git/refs", get(git_browser::api_list_refs))
725 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
726 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
727 .route(
732 "/export/pdf",
733 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
734 )
735 .route("/export-config", get(export_config_handler))
737 .route("/import-config", post(import_config_handler))
738 .route("/api/scan-profiles", get(api_list_scan_profiles))
740 .route("/api/scan-profiles", post(api_save_scan_profile))
741 .route(
742 "/api/scan-profiles/{id}",
743 axum::routing::delete(api_delete_scan_profile),
744 )
745 .route("/integrations", get(integrations::integrations_handler))
747 .route(
748 "/webhook-setup",
749 get(|| async { axum::response::Redirect::permanent("/integrations") }),
750 )
751 .route(
752 "/confluence-setup",
753 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
754 )
755 .route("/api/schedules", get(git_webhook::api_list_schedules))
756 .route("/api/schedules", post(git_webhook::api_create_schedule))
757 .route(
758 "/api/schedules",
759 axum::routing::delete(git_webhook::api_delete_schedule),
760 )
761 .route(
762 "/api/confluence/config",
763 get(confluence::api_get_confluence_config),
764 )
765 .route(
766 "/api/confluence/config",
767 post(confluence::api_save_confluence_config),
768 )
769 .route(
770 "/api/confluence/test",
771 post(confluence::api_test_confluence),
772 )
773 .route(
774 "/api/confluence/post",
775 post(confluence::api_post_to_confluence),
776 )
777 .route(
778 "/api/confluence/wiki-markup",
779 get(confluence::api_wiki_markup),
780 )
781 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
783 .route(
784 "/api/runs/{run_id}",
785 axum::routing::delete(delete_run_handler),
786 )
787 .route("/api/runs/cleanup", post(cleanup_runs_handler))
788 .route(
790 "/api/cleanup-policy",
791 get(api_get_cleanup_policy)
792 .post(api_save_cleanup_policy)
793 .delete(api_delete_cleanup_policy),
794 )
795 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
796 .route("/api-docs", get(api_docs_handler))
798 .route("/metrics", get(metrics_handler))
800 .route_layer(middleware::from_fn_with_state(
801 state.clone(),
802 auth::require_api_key,
803 ));
804
805 protected
806 .route("/healthz", get(healthz))
807 .route("/api/health", get(healthz))
808 .route("/api/version", get(api_version_handler))
809 .route("/api/openapi.yaml", get(openapi_yaml_handler))
810 .route("/llms.txt", get(llms_txt_handler))
811 .route("/llms-full.txt", get(llms_full_txt_handler))
812 .route("/badge/{metric}", get(badge_handler))
813 .route("/static/chart.js", get(chart_js_handler))
814 .route("/static/chart-report.js", get(report_chart_js_handler))
815 .route("/auth/login", get(auth::auth_login_get))
816 .route("/auth/login", post(auth::auth_login_post))
817 .route("/auth/logout", post(auth::auth_logout))
818 .route(
821 "/webhooks/github",
822 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
823 )
824 .route(
825 "/webhooks/gitlab",
826 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
827 )
828 .route(
829 "/webhooks/bitbucket",
830 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
831 )
832 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
833 .layer(middleware::from_fn(csrf_protect))
834 .layer(middleware::from_fn_with_state(
835 state.clone(),
836 add_security_headers,
837 ))
838 .layer(build_cors_layer(state.server_mode))
839 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
840 .with_state(state)
841}
842
843pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
846
847fn test_app_state(tmp_subdir: &str) -> AppState {
853 std::env::set_var("SLOC_HEADLESS", "1");
854 let tmp = std::env::temp_dir().join(tmp_subdir);
855 AppState {
856 base_config: AppConfig::default(),
857 artifacts: Arc::new(Mutex::new(HashMap::new())),
858 async_runs: Arc::new(Mutex::new(HashMap::new())),
859 registry: Arc::new(Mutex::new(ScanRegistry::default())),
860 registry_path: tmp.join("registry.json"),
861 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
862 server_mode: false,
863 allow_unauthenticated: false,
864 tls_enabled: false,
865 api_keys: Arc::new(vec![]),
866 rate_limiter: Arc::new(IpRateLimiter::new(
867 Duration::from_mins(1),
868 600,
869 10,
870 Duration::from_hours(1),
871 )),
872 trust_proxy: false,
873 trusted_proxy_ips: vec![],
874 git_clones_dir: tmp.join("git-clones"),
875 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
876 schedules_path: tmp.join("schedules.json"),
877 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
878 scan_profiles_path: tmp.join("scan_profiles.json"),
879 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
880 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
881 confluence_path: tmp.join("confluence_config.json"),
882 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
883 watched_dirs_path: tmp.join("watched_dirs.json"),
884 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
885 cleanup_policy_path: tmp.join("cleanup_policy.json"),
886 cleanup_task_handle: Arc::new(Mutex::new(None)),
887 }
888}
889
890pub fn make_test_router() -> Router {
892 build_router(test_app_state("sloc_test"))
893}
894
895pub fn make_test_router_with_key(api_key: &str) -> Router {
897 let mut state = test_app_state("sloc_test_key");
898 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
899 build_router(state)
900}
901
902pub fn make_test_router_server_mode() -> Router {
906 let mut state = test_app_state("sloc_test_server");
907 state.server_mode = true;
908 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
909 TEST_SERVER_MODE_API_KEY.to_owned(),
910 ))]);
911 build_router(state)
912}
913
914pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
920 let mut state = test_app_state("sloc_test_server_roots");
921 state.server_mode = true;
922 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
923 TEST_SERVER_MODE_API_KEY.to_owned(),
924 ))]);
925 state.base_config.discovery.allowed_scan_roots = roots;
926 build_router(state)
927}
928
929pub fn make_test_router_exhausted_semaphore() -> Router {
932 let mut state = test_app_state("sloc_test_exhaust");
933 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
934 build_router(state)
935}
936
937pub fn make_test_router_tight_rate_limit() -> Router {
940 let mut state = test_app_state("sloc_test_rate");
941 state.rate_limiter = Arc::new(IpRateLimiter::new(
942 Duration::from_mins(1),
943 2,
944 5,
945 Duration::from_secs(5),
946 ));
947 build_router(state)
948}
949
950pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
953 let mut state = test_app_state("sloc_test_auth_lockout");
954 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
955 state.rate_limiter = Arc::new(IpRateLimiter::new(
956 Duration::from_mins(1),
957 600,
958 2, Duration::from_millis(200), ));
961 build_router(state)
962}
963
964struct RuntimeSecurityConfig {
965 api_keys: Vec<secrecy::SecretBox<String>>,
966 tls_cert: Option<String>,
967 tls_key: Option<String>,
968 tls_enabled: bool,
969 trust_proxy: bool,
970 trusted_proxy_ips: Vec<IpAddr>,
971 rate_limiter: Arc<IpRateLimiter>,
972}
973
974fn allow_unauthenticated_server_mode() -> bool {
977 matches!(
978 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
979 Ok("1") | Ok("true") | Ok("TRUE")
980 )
981}
982
983fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
987 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
988}
989
990fn emit_server_mode_warnings(
993 server_mode: bool,
994 api_keys_empty: bool,
995 tls_enabled: bool,
996 trust_proxy: bool,
997 trusted_proxy_ips: &[IpAddr],
998) {
999 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1000 println!(
1004 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1005 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1006 outside a trusted, isolated network."
1007 );
1008 }
1009 if server_mode && !tls_enabled {
1010 println!(
1011 "WARNING: TLS is not configured. Traffic is cleartext. \
1012 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1013 or terminate TLS at a reverse proxy (nginx, caddy)."
1014 );
1015 }
1016 if server_mode {
1017 println!(
1018 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1019 to restrict cross-origin access (comma-separated)."
1020 );
1021 }
1022 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1023 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1024 println!(
1025 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1026 DISABLED for all git operations. Remove this variable before production use."
1027 );
1028 }
1029}
1030
1031fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1033 if trust_proxy {
1034 if trusted_proxy_ips.is_empty() {
1035 println!(
1036 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1037 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1038 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1039 );
1040 } else {
1041 println!(
1042 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1043 trusted_proxy_ips
1044 .iter()
1045 .map(std::string::ToString::to_string)
1046 .collect::<Vec<_>>()
1047 .join(", ")
1048 );
1049 }
1050 } else if server_mode {
1051 println!(
1052 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1053 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1054 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1055 enable per-client rate limiting via X-Forwarded-For."
1056 );
1057 }
1058}
1059
1060fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1061 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1062 .or_else(|_| std::env::var("SLOC_API_KEY"))
1063 .unwrap_or_default()
1064 .split(',')
1065 .map(str::trim)
1066 .filter(|s| !s.is_empty())
1067 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1068 .collect();
1069 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1070 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1071 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1072 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1073 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1074 .unwrap_or_default()
1075 .split(',')
1076 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1077 .collect();
1078 emit_server_mode_warnings(
1079 server_mode,
1080 api_keys.is_empty(),
1081 tls_enabled,
1082 trust_proxy,
1083 &trusted_proxy_ips,
1084 );
1085 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1086 .ok()
1087 .and_then(|v| v.parse::<u32>().ok())
1088 .unwrap_or(10);
1089 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1090 .ok()
1091 .and_then(|v| v.parse::<u64>().ok())
1092 .unwrap_or(3600);
1093 let default_rpm: usize = if server_mode { 120 } else { 600 };
1097 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1098 .ok()
1099 .and_then(|v| v.parse::<usize>().ok())
1100 .unwrap_or(default_rpm);
1101 let rate_limiter = Arc::new(IpRateLimiter::new(
1102 Duration::from_mins(1),
1103 rate_limit_rpm,
1104 auth_lockout_threshold,
1105 Duration::from_secs(auth_lockout_secs),
1106 ));
1107 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1108 RuntimeSecurityConfig {
1109 api_keys,
1110 tls_cert,
1111 tls_key,
1112 tls_enabled,
1113 trust_proxy,
1114 trusted_proxy_ips,
1115 rate_limiter,
1116 }
1117}
1118
1119#[allow(clippy::too_many_lines)]
1128pub async fn serve(config: AppConfig) -> Result<()> {
1129 let bind_address = config.web.bind_address.clone();
1130 let server_mode = config.web.server_mode;
1131 let output_root = resolve_output_root(None);
1132 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1134 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1135 let mut registry = ScanRegistry::load(®istry_path);
1136 registry.prune_stale();
1137 let _ = registry.save(®istry_path);
1138
1139 let sec = load_runtime_security_config(server_mode);
1140 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1144 audit::record(
1145 "server_start_refused",
1146 "denied",
1147 &[(
1148 "reason",
1149 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1150 )],
1151 );
1152 anyhow::bail!(
1153 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1154 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1155 unauthenticated server on a trusted, isolated network, explicitly set \
1156 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1157 );
1158 }
1159 if server_mode && sec.api_keys.is_empty() {
1160 audit::record("server_start_unauthenticated", "warning", &[]);
1161 }
1162 spawn_upload_staging_cleanup();
1163
1164 let git_clones_dir = resolve_git_clones_dir(&output_root);
1165 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1166 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1167 let schedules = ScheduleStore::load(&schedules_path);
1168 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1169 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1170 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1171 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1172 |_| output_root.join("confluence_config.json"),
1173 PathBuf::from,
1174 );
1175 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1176 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1177 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1178 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1179 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1180 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1181 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1182
1183 let state = AppState {
1184 base_config: config,
1185 artifacts: Arc::new(Mutex::new(HashMap::new())),
1186 async_runs: Arc::new(Mutex::new(HashMap::new())),
1187 registry: Arc::new(Mutex::new(registry)),
1188 registry_path,
1189 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1190 server_mode,
1191 allow_unauthenticated: allow_unauthenticated_server_mode(),
1192 tls_enabled: sec.tls_enabled,
1193 api_keys: Arc::new(sec.api_keys),
1194 rate_limiter: sec.rate_limiter,
1195 trust_proxy: sec.trust_proxy,
1196 trusted_proxy_ips: sec.trusted_proxy_ips,
1197 git_clones_dir,
1198 schedules: Arc::new(Mutex::new(schedules)),
1199 schedules_path,
1200 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1201 scan_profiles_path,
1202 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1203 confluence: Arc::new(Mutex::new(confluence)),
1204 confluence_path,
1205 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1206 watched_dirs_path,
1207 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1208 cleanup_policy_path,
1209 cleanup_task_handle: Arc::new(Mutex::new(None)),
1210 };
1211
1212 restart_poll_schedules(&state).await;
1213 warn_insecure_gitlab_webhooks(&state).await;
1214
1215 {
1217 let enabled = state
1218 .cleanup_policy
1219 .lock()
1220 .await
1221 .policy
1222 .as_ref()
1223 .is_some_and(|p| p.enabled);
1224 if enabled {
1225 let handle = spawn_cleanup_policy_task(state.clone());
1226 *state.cleanup_task_handle.lock().await = Some(handle);
1227 }
1228 }
1229
1230 let app = build_router(state.clone());
1231
1232 let preferred: SocketAddr = bind_address
1237 .parse()
1238 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1239 let (listener, addr) = {
1240 let candidates = (0u16..=9).map(|offset| {
1241 let mut a = preferred;
1242 a.set_port(preferred.port().saturating_add(offset));
1243 a
1244 });
1245 let mut found = None;
1246 for candidate in candidates {
1247 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1248 found = Some((l, candidate));
1249 break;
1250 }
1251 }
1252 found.ok_or_else(|| {
1253 anyhow::anyhow!(
1254 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1255 bind_address,
1256 preferred.port(),
1257 preferred.port().saturating_add(9)
1258 )
1259 })?
1260 };
1261 if addr != preferred {
1262 eprintln!(
1263 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1264 using {} instead.",
1265 preferred.port(),
1266 addr.port()
1267 );
1268 }
1269
1270 if sec.tls_enabled {
1271 let cert_path = sec
1272 .tls_cert
1273 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1274 let key_path = sec
1275 .tls_key
1276 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1277 let tls_config = build_tls_config(&cert_path, &key_path)
1278 .context("failed to load TLS certificate/key")?;
1279 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1280
1281 let url = format!("https://{addr}/");
1282 println!("OxideSLOC server running at {url} (TLS)");
1283 println!("Use Ctrl+C to stop.");
1284
1285 return serve_tls(listener, app, acceptor, server_mode).await;
1286 }
1287
1288 let url = format!("http://{addr}/");
1289 log_startup_url(&url, server_mode);
1290
1291 axum::serve(
1292 listener,
1293 app.into_make_service_with_connect_info::<SocketAddr>(),
1294 )
1295 .with_graceful_shutdown(shutdown_signal(server_mode))
1296 .await
1297 .context("web server terminated unexpectedly")
1298}
1299
1300fn primary_lan_ip() -> Option<String> {
1304 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1305 socket.connect("8.8.8.8:80").ok()?;
1306 let addr = socket.local_addr().ok()?;
1307 let ip = addr.ip();
1308 if ip.is_loopback() {
1309 return None;
1310 }
1311 Some(ip.to_string())
1312}
1313
1314fn log_startup_url(url: &str, server_mode: bool) {
1316 if server_mode {
1317 println!("OxideSLOC server running at {url}");
1318 println!("Use Ctrl+C to stop.");
1319 } else {
1320 println!("OxideSLOC local web UI running at {url}");
1321 println!("Press Ctrl+C to stop the server.");
1322 let open_url = url.to_owned();
1323 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1324 }
1325}
1326
1327fn open_browser_tab(url: &str) {
1329 #[cfg(target_os = "windows")]
1334 let _ = std::process::Command::new("rundll32")
1335 .args(["url.dll,FileProtocolHandler", url])
1336 .stdout(Stdio::null())
1337 .stderr(Stdio::null())
1338 .spawn();
1339 #[cfg(target_os = "macos")]
1340 let _ = std::process::Command::new("open")
1341 .arg(url)
1342 .stdout(Stdio::null())
1343 .stderr(Stdio::null())
1344 .spawn();
1345 #[cfg(target_os = "linux")]
1346 let _ = std::process::Command::new("xdg-open")
1347 .arg(url)
1348 .stdout(Stdio::null())
1349 .stderr(Stdio::null())
1350 .spawn();
1351}
1352
1353async fn shutdown_signal(server_mode: bool) {
1355 if tokio::signal::ctrl_c().await.is_ok() {
1356 println!();
1357 if server_mode {
1358 println!("Shutting down OxideSLOC server...");
1359 } else {
1360 println!("Shutting down OxideSLOC local web UI...");
1361 }
1362 println!("Server stopped cleanly.");
1363 }
1364}
1365
1366fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1368 use rustls_pki_types::pem::PemObject;
1369 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1370
1371 let cert_bytes =
1372 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1373 let key_bytes =
1374 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1375
1376 let cert_chain: Vec<CertificateDer<'static>> =
1377 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1378 .collect::<std::result::Result<_, _>>()
1379 .context("failed to parse TLS certificates")?;
1380
1381 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1382 .context("failed to parse TLS private key")?;
1383
1384 rustls::ServerConfig::builder()
1385 .with_no_client_auth()
1386 .with_single_cert(cert_chain, key)
1387 .context("failed to build TLS server config")
1388}
1389
1390async fn serve_tls(
1392 listener: tokio::net::TcpListener,
1393 app: Router,
1394 acceptor: tokio_rustls::TlsAcceptor,
1395 server_mode: bool,
1396) -> Result<()> {
1397 use hyper_util::rt::{TokioExecutor, TokioIo};
1398 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1399 use hyper_util::service::TowerToHyperService;
1400 use tower::{Service, ServiceExt};
1401
1402 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1403
1404 loop {
1405 tokio::select! {
1406 biased;
1407 _ = tokio::signal::ctrl_c() => {
1408 println!();
1409 if server_mode {
1410 println!("Shutting down OxideSLOC server...");
1411 } else {
1412 println!("Shutting down OxideSLOC local web UI...");
1413 }
1414 println!("Server stopped cleanly.");
1415 return Ok(());
1416 }
1417 result = listener.accept() => {
1418 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1419 let acceptor = acceptor.clone();
1420 let mut factory = make_svc.clone();
1421
1422 tokio::spawn(async move {
1423 let tls = match acceptor.accept(tcp).await {
1424 Ok(s) => s,
1425 Err(e) => {
1426 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1427 return;
1428 }
1429 };
1430 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1431 Ok(f) => match Service::call(f, peer_addr).await {
1432 Ok(s) => s,
1433 Err(_) => return,
1434 },
1435 Err(_) => return,
1436 };
1437 let io = TokioIo::new(tls);
1438 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1439 .serve_connection(io, TowerToHyperService::new(svc))
1440 .await
1441 {
1442 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1443 }
1444 });
1445 }
1446 }
1447 }
1448}
1449
1450fn build_cors_layer(server_mode: bool) -> CorsLayer {
1453 if server_mode {
1454 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1455 .unwrap_or_default()
1456 .split(',')
1457 .filter(|s| !s.is_empty())
1458 .filter_map(|s| s.trim().parse().ok())
1459 .collect();
1460 if allowed.is_empty() {
1461 return CorsLayer::new();
1462 }
1463 CorsLayer::new()
1464 .allow_origin(AllowOrigin::list(allowed))
1465 .allow_methods(AllowMethods::list([
1466 axum::http::Method::GET,
1467 axum::http::Method::POST,
1468 ]))
1469 .allow_headers(AllowHeaders::list([
1470 axum::http::header::AUTHORIZATION,
1471 axum::http::header::CONTENT_TYPE,
1472 ]))
1473 } else {
1474 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1475 let s = origin.to_str().unwrap_or("");
1476 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1477 }))
1478 }
1479}
1480
1481async fn add_security_headers(
1482 State(state): State<AppState>,
1483 mut req: Request<Body>,
1484 next: Next,
1485) -> Response {
1486 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1487 req.extensions_mut().insert(CspNonce(nonce.clone()));
1488 let mut resp = next.run(req).await;
1489 inject_page_fade_into_html(&mut resp, &nonce).await;
1490 let h = resp.headers_mut();
1491 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1492 h.insert(
1493 "X-Content-Type-Options",
1494 HeaderValue::from_static("nosniff"),
1495 );
1496 h.insert(
1497 "Referrer-Policy",
1498 HeaderValue::from_static("strict-origin-when-cross-origin"),
1499 );
1500 let csp = format!(
1501 "default-src 'self'; \
1502 base-uri 'self'; \
1503 form-action 'self'; \
1504 style-src 'self' 'unsafe-inline'; \
1505 img-src 'self' data: blob:; \
1506 script-src 'self' 'nonce-{nonce}'; \
1507 font-src 'self' data:; \
1508 object-src 'none'; \
1509 frame-ancestors 'none'"
1510 );
1511 h.insert(
1512 "Content-Security-Policy",
1513 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1514 HeaderValue::from_static(
1515 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1516 )
1517 }),
1518 );
1519 h.insert(
1520 "X-Permitted-Cross-Domain-Policies",
1521 HeaderValue::from_static("none"),
1522 );
1523 h.insert(
1524 "Permissions-Policy",
1525 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1526 );
1527 h.insert(
1528 "Cross-Origin-Opener-Policy",
1529 HeaderValue::from_static("same-origin"),
1530 );
1531 h.insert(
1532 "Cross-Origin-Resource-Policy",
1533 HeaderValue::from_static("same-origin"),
1534 );
1535 h.insert(
1538 "Cross-Origin-Embedder-Policy",
1539 HeaderValue::from_static("require-corp"),
1540 );
1541 if state.tls_enabled {
1542 h.insert(
1543 "Strict-Transport-Security",
1544 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1545 );
1546 }
1547 resp
1548}
1549
1550async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1564 use axum::http::Method;
1565
1566 let is_state_changing = matches!(
1567 *req.method(),
1568 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1569 );
1570 let path = req.uri().path();
1571 let has_token_auth = req.headers().contains_key("X-API-Key")
1572 || req
1573 .headers()
1574 .get(header::AUTHORIZATION)
1575 .and_then(|v| v.to_str().ok())
1576 .is_some_and(|v| v.starts_with("Bearer "));
1577
1578 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1579 return next.run(req).await;
1580 }
1581
1582 let headers = req.headers();
1583 let header_str = |name: &header::HeaderName| {
1584 headers
1585 .get(name)
1586 .and_then(|v| v.to_str().ok())
1587 .map(str::to_owned)
1588 };
1589 let origin = header_str(&header::ORIGIN);
1590 let referer = header_str(&header::REFERER);
1591 let host = header_str(&header::HOST);
1592
1593 let authority_of = |url: &str| -> Option<String> {
1595 url.split_once("://")
1596 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1597 };
1598
1599 let source_authority = origin
1600 .as_deref()
1601 .and_then(authority_of)
1602 .or_else(|| referer.as_deref().and_then(authority_of));
1603
1604 match (source_authority, host) {
1605 (None, _) => next.run(req).await,
1607 (Some(src), Some(h)) if src == h => next.run(req).await,
1608 (Some(src), host) => {
1609 tracing::warn!(
1610 event = "csrf_rejected",
1611 path = %path,
1612 origin = %src,
1613 host = ?host,
1614 "Cross-origin state-changing request rejected (CSRF guard)"
1615 );
1616 (
1617 StatusCode::FORBIDDEN,
1618 "403 Forbidden — cross-origin request rejected\n",
1619 )
1620 .into_response()
1621 }
1622 }
1623}
1624
1625fn page_fade_html(nonce: &str) -> String {
1633 const STYLE: &str = r"<style>
1642@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1643.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1644body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1645@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1646</style>";
1647 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');});})();";
1656 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1657}
1658
1659fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1674 const TPL: &str = r#"<style nonce="__N__">
1675html.sloc-pending body{visibility:hidden;}
1676html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1677#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%);}
1678#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1679body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1680body.pdf-mode #rpt-loading-overlay{display:none!important;}
1681.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1682.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;}
1683.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;}
1684@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1685@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1686body.dark-theme .rpt-bg-blob{opacity:.36;}
1687.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;}
1688@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1689body.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);}
1690.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1691.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1692.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1693.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));}
1694@keyframes rpt-spin{to{transform:rotate(360deg);}}
1695.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;}
1696body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1697body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1698.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1699.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;}
1700@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1701.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1702.rpt-dot:nth-child(2){animation-delay:.28s;}
1703.rpt-dot:nth-child(3){animation-delay:.56s;}
1704@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1705.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1706.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1707.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;}
1708body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1709@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;}}
1710</style>
1711<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1712<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>
1713<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1714 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1715 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1716 <div class="rpt-load-card">
1717 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1718 <div class="rpt-spinner-wrap">
1719 <div class="rpt-spinner-track"></div>
1720 <div class="rpt-spinner"></div>
1721 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1722 </div>
1723 <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>
1724 <div class="rpt-status" id="rpt-status">__LABEL__</div>
1725 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1726 </div>
1727</div>
1728<script nonce="__N__">
1729(function(){
1730 var ov=document.getElementById('rpt-loading-overlay');
1731 var root=document.documentElement;
1732 function reveal(){root.classList.remove('sloc-pending');}
1733 if(!ov){reveal();return;}
1734 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1735 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1736 var mi=0,prog=0,done=false,start=Date.now();
1737 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1738 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1739 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1740 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1741 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1742 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1743 setProg(8);
1744 var msgTimer=setInterval(nextMsg,700);
1745 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);
1746 // These pages draw charts into known SVG containers that start empty and are
1747 // filled by JS once layout is available (some only after a ResizeObserver pass
1748 // post-`load`). Treat the page as ready only once every chart container present
1749 // actually has rendered content, so the overlay never lifts on a half-drawn page.
1750 function chartsRendered(){
1751 var sel=['#cmp-tl-svg','#mc-chart'];
1752 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1753 return true;
1754 }
1755 function finish(){
1756 if(done)return;done=true;
1757 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1758 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1759 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1760 reveal();
1761 requestAnimationFrame(function(){requestAnimationFrame(function(){
1762 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1763 });});
1764 }
1765 // Wait for `load` (resources + first layout), then poll until the charts have
1766 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1767 function afterLoad(){
1768 var loadAt=Date.now();
1769 (function poll(){
1770 if(done)return;
1771 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1772 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1773 return;
1774 }
1775 requestAnimationFrame(poll);
1776 })();
1777 }
1778 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1779 // Absolute safety net: never let the gate/overlay get stuck.
1780 setTimeout(function(){if(!done)finish();},HARD_CAP);
1781})();
1782</script>"#;
1783 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1784}
1785
1786fn sloc_toast_assets(nonce: &str) -> String {
1801 const TPL: &str = r#"<style nonce="__N__">
1802#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;}
1803.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);}
1804.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1805.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1806.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;}
1807.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1808.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1809.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1810.sloc-toast-success{border-color:#bfe0cc;}
1811.sloc-toast-error{border-color:#e6b3b3;}
1812.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1813.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;}
1814@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1815.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;}
1816.sloc-toast-x:hover{opacity:1;}
1817body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1818body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1819body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1820body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1821@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1822</style>
1823<script nonce="__N__">
1824(function(){
1825 if(window.slocToast)return;
1826 function wrap(){
1827 var w=document.getElementById('sloc-toast-wrap');
1828 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);}
1829 return w;
1830 }
1831 window.slocToast=function(msg,opts){
1832 opts=opts||{};
1833 var type=opts.type||'info';
1834 var loading=type==='loading';
1835 var t=document.createElement('div');
1836 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1837 t.setAttribute('role',type==='error'?'alert':'status');
1838 var ico=loading
1839 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1840 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1841 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1842 t.querySelector('.sloc-toast-msg').textContent=String(msg);
1843 wrap().appendChild(t);
1844 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1845 var gone=false,timer=null;
1846 function close(){
1847 if(gone)return;gone=true;if(timer)clearTimeout(timer);
1848 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1849 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1850 }
1851 t.querySelector('.sloc-toast-x').addEventListener('click',close);
1852 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1853 if(ttl>0)timer=setTimeout(close,ttl);
1854 return {dismiss:close,el:t};
1855 };
1856 window.slocExportPdf=function(o){
1857 o=o||{};
1858 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1859 if(btn&&btn.disabled)return;
1860 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1861 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1862 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1863 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1864 .then(function(blob){
1865 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1866 document.body.appendChild(a);a.click();document.body.removeChild(a);
1867 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1868 load.dismiss();
1869 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1870 })
1871 .catch(function(e){
1872 load.dismiss();
1873 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1874 })
1875 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1876 };
1877})();
1878</script>"#;
1879 TPL.replace("__N__", nonce)
1880}
1881
1882async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1887 let is_html = resp
1888 .headers()
1889 .get(header::CONTENT_TYPE)
1890 .and_then(|v| v.to_str().ok())
1891 .is_some_and(|v| v.starts_with("text/html"));
1892 if !is_html {
1893 return;
1894 }
1895 let body = std::mem::replace(resp.body_mut(), Body::empty());
1896 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1897 return;
1898 };
1899 let html = match String::from_utf8(bytes.to_vec()) {
1900 Ok(s) => s,
1901 Err(e) => {
1902 *resp.body_mut() = Body::from(e.into_bytes());
1903 return;
1904 }
1905 };
1906 if html.contains("id=\"rpt-loading-overlay\"") {
1907 *resp.body_mut() = Body::from(html);
1908 return;
1909 }
1910 let insert_at = html
1914 .find("<body")
1915 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1916 .or_else(|| {
1917 let lower = html.to_ascii_lowercase();
1918 lower
1919 .find("<body")
1920 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1921 });
1922 let new_html = match insert_at {
1923 Some(at) => {
1924 let mut out = String::with_capacity(html.len() + 1024);
1925 out.push_str(&html[..at]);
1926 out.push_str(&page_fade_html(nonce));
1927 out.push_str(&html[at..]);
1928 out
1929 }
1930 None => html,
1931 };
1932 resp.headers_mut().remove(header::CONTENT_LENGTH);
1933 *resp.body_mut() = Body::from(new_html);
1934}
1935
1936async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1937 let peer_ip = req
1938 .extensions()
1939 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1940 .map(|c| c.0.ip());
1941
1942 let ip = peer_ip
1946 .and_then(|peer| {
1947 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1948 req.headers()
1949 .get("X-Forwarded-For")
1950 .and_then(|v| v.to_str().ok())
1951 .and_then(|s| s.split(',').next())
1952 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1953 } else {
1954 None
1955 }
1956 })
1957 .or(peer_ip)
1958 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1959
1960 if !state.rate_limiter.is_allowed(ip) {
1961 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1962 path = %req.uri().path(), "Rate limit exceeded");
1963 return (
1964 StatusCode::TOO_MANY_REQUESTS,
1965 [(header::RETRY_AFTER, "60")],
1966 "429 Too Many Requests\n",
1967 )
1968 .into_response();
1969 }
1970 next.run(req).await
1971}
1972
1973async fn splash(
1974 State(state): State<AppState>,
1975 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1976) -> impl IntoResponse {
1977 let lan_ip = if state.server_mode {
1978 primary_lan_ip()
1979 } else {
1980 None
1981 };
1982 let port = state
1983 .base_config
1984 .web
1985 .bind_address
1986 .rsplit(':')
1987 .next()
1988 .and_then(|p| p.parse::<u16>().ok())
1989 .unwrap_or(4317);
1990 let has_api_key = !state.api_keys.is_empty();
1991 let template = SplashTemplate {
1992 csp_nonce,
1993 server_mode: state.server_mode,
1994 lan_ip,
1995 port,
1996 version: env!("CARGO_PKG_VERSION"),
1997 has_api_key,
1998 };
1999 Html(
2000 template
2001 .render()
2002 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2003 )
2004}
2005
2006async fn index(
2007 State(state): State<AppState>,
2008 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2009 Query(query): Query<IndexQuery>,
2010) -> impl IntoResponse {
2011 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2012 let policy = query
2013 .mixed_line_policy
2014 .unwrap_or_else(|| "code_only".to_string());
2015 let behavior = query
2016 .binary_file_behavior
2017 .unwrap_or_else(|| "skip".to_string());
2018 let cfg = ScanConfig {
2019 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2020 path: query.path.unwrap_or_default(),
2021 include_globs: query.include_globs.unwrap_or_default(),
2022 exclude_globs: query.exclude_globs.unwrap_or_default(),
2023 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2024 mixed_line_policy: policy,
2025 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2026 != Some("off"),
2027 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2028 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2029 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2030 != Some("disabled"),
2031 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2032 binary_file_behavior: behavior,
2033 output_dir: query.output_dir.unwrap_or_default(),
2034 report_title: query.report_title.unwrap_or_default(),
2035 continuation_line_policy: query
2036 .continuation_line_policy
2037 .unwrap_or_else(default_each_physical_line),
2038 blank_in_block_comment_policy: query
2039 .blank_in_block_comment_policy
2040 .unwrap_or_else(default_count_as_comment),
2041 count_compiler_directives: query.count_compiler_directives.as_deref()
2042 != Some("disabled"),
2043 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2044 style_col_threshold: query
2045 .style_col_threshold
2046 .as_deref()
2047 .and_then(|s| s.parse().ok())
2048 .unwrap_or(80),
2049 style_score_threshold: query
2050 .style_score_threshold
2051 .as_deref()
2052 .and_then(|s| s.parse().ok())
2053 .unwrap_or(0),
2054 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2055 coverage_file: query.coverage_file.unwrap_or_default(),
2056 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2057 complexity_alert: query
2058 .complexity_alert
2059 .as_deref()
2060 .and_then(|s| s.parse().ok())
2061 .unwrap_or(0),
2062 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2063 activity_window: query
2064 .activity_window
2065 .as_deref()
2066 .and_then(|s| s.parse().ok())
2067 .unwrap_or(90),
2068 };
2069 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2070 } else {
2071 "{}".to_string()
2072 };
2073
2074 let git_repo = query.git_repo.unwrap_or_default();
2075 let git_ref = query.git_ref.unwrap_or_default();
2076
2077 let git_label = make_git_label(&git_repo, &git_ref);
2078 let git_output_dir = if git_label.is_empty() {
2079 String::new()
2080 } else {
2081 desktop_dir().join(&git_label).display().to_string()
2082 };
2083 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2084 let git_output_dir_json =
2085 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2086
2087 let template = IndexTemplate {
2088 version: env!("CARGO_PKG_VERSION"),
2089 prefill_json,
2090 csp_nonce,
2091 git_repo,
2092 git_ref,
2093 git_label_json,
2094 git_output_dir_json,
2095 server_mode: state.server_mode,
2096 };
2097
2098 Html(
2099 template
2100 .render()
2101 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2102 )
2103}
2104
2105async fn scan_setup_handler(
2106 State(state): State<AppState>,
2107 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2108) -> impl IntoResponse {
2109 let recent_scans_json = {
2110 let arr: Vec<serde_json::Value> = {
2111 let reg = state.registry.lock().await;
2112 reg.entries
2113 .iter()
2114 .rev()
2115 .take(6)
2116 .map(|e| {
2117 let run_dir = e
2118 .html_path
2119 .as_ref()
2120 .or(e.json_path.as_ref())
2121 .and_then(|p| p.parent().map(PathBuf::from));
2122 let config_val: Option<serde_json::Value> = run_dir
2123 .and_then(|d| find_scan_config_in_dir(&d))
2124 .and_then(|p| fs::read_to_string(&p).ok())
2125 .and_then(|s| serde_json::from_str(&s).ok());
2126 serde_json::json!({
2127 "project_label": e.project_label,
2128 "timestamp": fmt_la_time(e.timestamp_utc),
2129 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2130 "config": config_val,
2131 })
2132 })
2133 .collect()
2134 };
2135 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2136 };
2137
2138 let template = ScanSetupTemplate {
2139 version: env!("CARGO_PKG_VERSION"),
2140 recent_scans_json,
2141 csp_nonce,
2142 };
2143 Html(
2144 template
2145 .render()
2146 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2147 )
2148}
2149
2150async fn healthz() -> &'static str {
2151 "ok"
2152}
2153
2154async fn api_version_handler() -> impl IntoResponse {
2155 axum::Json(serde_json::json!({
2156 "name": "oxide-sloc",
2157 "version": env!("CARGO_PKG_VERSION"),
2158 }))
2159}
2160
2161fn prom_runs_total() -> &'static prometheus::IntCounter {
2164 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2165 COUNTER.get_or_init(|| {
2166 prometheus::register_int_counter!(
2167 "oxide_sloc_runs_total",
2168 "Total number of completed analysis runs"
2169 )
2170 .expect("failed to register oxide_sloc_runs_total counter")
2171 })
2172}
2173
2174async fn metrics_handler() -> impl IntoResponse {
2175 use prometheus::Encoder as _;
2176 let mut buf = Vec::new();
2177 let encoder = prometheus::TextEncoder::new();
2178 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2179 (
2180 [(
2181 axum::http::header::CONTENT_TYPE,
2182 "text/plain; version=0.0.4; charset=utf-8",
2183 )],
2184 buf,
2185 )
2186}
2187
2188static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2189
2190async fn openapi_yaml_handler() -> impl IntoResponse {
2191 (
2192 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2193 OPENAPI_YAML,
2194 )
2195}
2196
2197static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2198static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2199
2200async fn llms_txt_handler() -> impl IntoResponse {
2201 (
2202 [
2203 (
2204 axum::http::header::CONTENT_TYPE,
2205 "text/plain; charset=utf-8",
2206 ),
2207 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2208 ],
2209 LLMS_TXT,
2210 )
2211}
2212
2213async fn llms_full_txt_handler() -> impl IntoResponse {
2214 (
2215 [
2216 (
2217 axum::http::header::CONTENT_TYPE,
2218 "text/plain; charset=utf-8",
2219 ),
2220 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2221 ],
2222 LLMS_FULL_TXT,
2223 )
2224}
2225
2226async fn api_docs_handler(
2227 State(state): State<AppState>,
2228 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2229) -> impl IntoResponse {
2230 let has_api_key = !state.api_keys.is_empty();
2231 Html(
2232 ApiDocsTemplate {
2233 has_api_key,
2234 csp_nonce,
2235 version: env!("CARGO_PKG_VERSION"),
2236 }
2237 .render()
2238 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2239 )
2240}
2241
2242async fn chart_js_handler() -> impl IntoResponse {
2243 (
2244 [
2245 (
2246 header::CONTENT_TYPE,
2247 "application/javascript; charset=utf-8",
2248 ),
2249 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2250 ],
2251 CHART_JS,
2252 )
2253}
2254
2255async fn report_chart_js_handler() -> impl IntoResponse {
2256 (
2257 [
2258 (
2259 header::CONTENT_TYPE,
2260 "application/javascript; charset=utf-8",
2261 ),
2262 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2263 ],
2264 REPORT_CHART_JS,
2265 )
2266}
2267
2268#[derive(Debug, Deserialize)]
2269struct AnalyzeForm {
2270 path: String,
2271 git_repo: Option<String>,
2272 git_ref: Option<String>,
2273 mixed_line_policy: Option<MixedLinePolicy>,
2274 python_docstrings_as_comments: Option<String>,
2275 generated_file_detection: Option<String>,
2276 minified_file_detection: Option<String>,
2277 vendor_directory_detection: Option<String>,
2278 include_lockfiles: Option<String>,
2279 binary_file_behavior: Option<BinaryFileBehavior>,
2280 output_dir: Option<String>,
2281 report_title: Option<String>,
2282 report_header_footer: Option<String>,
2283 include_globs: Option<String>,
2284 exclude_globs: Option<String>,
2285 submodule_breakdown: Option<String>,
2286 coverage_file: Option<String>,
2287 continuation_line_policy: Option<ContinuationLinePolicy>,
2288 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2289 count_compiler_directives: Option<String>,
2290 style_col_threshold: Option<String>,
2291 style_analysis_enabled: Option<String>,
2292 style_score_threshold: Option<String>,
2293 style_lang_scope: Option<String>,
2294 cocomo_mode: Option<String>,
2296 complexity_alert: Option<String>,
2298 exclude_duplicates: Option<String>,
2300 activity_window: Option<String>,
2302}
2303
2304#[allow(clippy::struct_excessive_bools)]
2305#[derive(Debug, Serialize, Deserialize, Clone)]
2306struct ScanConfig {
2307 oxide_sloc_version: String,
2308 path: String,
2309 include_globs: String,
2310 exclude_globs: String,
2311 submodule_breakdown: bool,
2312 mixed_line_policy: String,
2313 python_docstrings_as_comments: bool,
2314 generated_file_detection: bool,
2315 minified_file_detection: bool,
2316 vendor_directory_detection: bool,
2317 include_lockfiles: bool,
2318 binary_file_behavior: String,
2319 output_dir: String,
2320 report_title: String,
2321 #[serde(default = "default_each_physical_line")]
2323 continuation_line_policy: String,
2324 #[serde(default = "default_count_as_comment")]
2325 blank_in_block_comment_policy: String,
2326 #[serde(default = "default_true_bool")]
2327 count_compiler_directives: bool,
2328 #[serde(default = "default_true_bool")]
2329 style_analysis_enabled: bool,
2330 #[serde(default = "default_style_col_threshold")]
2331 style_col_threshold: u16,
2332 #[serde(default)]
2333 style_score_threshold: u8,
2334 #[serde(default = "default_all_scope")]
2335 style_lang_scope: String,
2336 #[serde(default)]
2337 coverage_file: String,
2338 #[serde(default = "default_organic")]
2339 cocomo_mode: String,
2340 #[serde(default)]
2341 complexity_alert: u32,
2342 #[serde(default)]
2343 exclude_duplicates: bool,
2344 #[serde(default = "default_activity_window")]
2346 activity_window: u32,
2347}
2348
2349const fn default_activity_window() -> u32 {
2350 90
2351}
2352
2353fn default_each_physical_line() -> String {
2354 "each_physical_line".to_string()
2355}
2356fn default_count_as_comment() -> String {
2357 "count_as_comment".to_string()
2358}
2359const fn default_true_bool() -> bool {
2360 true
2361}
2362const fn default_style_col_threshold() -> u16 {
2363 80
2364}
2365fn default_all_scope() -> String {
2366 "all".to_string()
2367}
2368fn default_organic() -> String {
2369 "organic".to_string()
2370}
2371
2372#[derive(Debug, Deserialize, Default)]
2373struct IndexQuery {
2374 path: Option<String>,
2375 include_globs: Option<String>,
2376 exclude_globs: Option<String>,
2377 submodule_breakdown: Option<String>,
2378 mixed_line_policy: Option<String>,
2379 python_docstrings_as_comments: Option<String>,
2380 generated_file_detection: Option<String>,
2381 minified_file_detection: Option<String>,
2382 vendor_directory_detection: Option<String>,
2383 include_lockfiles: Option<String>,
2384 binary_file_behavior: Option<String>,
2385 output_dir: Option<String>,
2386 report_title: Option<String>,
2387 prefilled: Option<String>,
2388 git_repo: Option<String>,
2389 git_ref: Option<String>,
2390 continuation_line_policy: Option<String>,
2392 blank_in_block_comment_policy: Option<String>,
2393 count_compiler_directives: Option<String>,
2394 style_analysis_enabled: Option<String>,
2395 style_col_threshold: Option<String>,
2396 style_score_threshold: Option<String>,
2397 style_lang_scope: Option<String>,
2398 coverage_file: Option<String>,
2399 cocomo_mode: Option<String>,
2400 complexity_alert: Option<String>,
2401 exclude_duplicates: Option<String>,
2402 activity_window: Option<String>,
2403}
2404
2405#[derive(Debug, Deserialize)]
2406struct PreviewQuery {
2407 path: Option<String>,
2408 include_globs: Option<String>,
2409 exclude_globs: Option<String>,
2410}
2411
2412#[cfg(feature = "native-dialog")]
2413#[derive(Debug, Deserialize)]
2414struct PickDirectoryQuery {
2415 kind: Option<String>,
2416 current: Option<String>,
2417}
2418
2419#[cfg(not(feature = "native-dialog"))]
2420#[derive(Debug, Deserialize)]
2421struct PickDirectoryQuery {}
2422
2423#[derive(Debug, Deserialize, Default)]
2424struct ArtifactQuery {
2425 download: Option<String>,
2426}
2427
2428#[cfg(feature = "native-dialog")]
2429#[derive(Debug, Serialize)]
2430struct PickDirectoryResponse {
2431 selected_path: Option<String>,
2432 cancelled: bool,
2433}
2434
2435#[cfg(feature = "native-dialog")]
2436async fn pick_directory_handler(
2437 State(state): State<AppState>,
2438 Query(query): Query<PickDirectoryQuery>,
2439) -> Response {
2440 if state.server_mode {
2441 return StatusCode::NOT_FOUND.into_response();
2442 }
2443 if std::env::var("SLOC_HEADLESS").is_ok() {
2445 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2446 .into_response();
2447 }
2448
2449 let is_coverage = query.kind.as_deref() == Some("coverage");
2450 let title = match query.kind.as_deref() {
2451 Some("output") => "Select output directory",
2452 Some("reports") => "Select folder containing saved reports",
2453 Some("coverage") => "Select LCOV coverage file",
2454 _ => "Select project directory",
2455 }
2456 .to_owned();
2457 let current = query.current.clone();
2458
2459 let picked = tokio::task::spawn_blocking(move || {
2460 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2463 let fg_tid = win_dialog_focus::attach_to_foreground();
2464 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2465 win_dialog_focus::flash_dialog_when_ready(title.clone());
2466
2467 let mut dialog = rfd::FileDialog::new().set_title(&title);
2468 if let Some(current) = current.as_deref() {
2469 let resolved = resolve_input_path(current);
2470 let seed = if resolved.is_dir() {
2471 Some(resolved)
2472 } else {
2473 resolved.parent().map(Path::to_path_buf)
2474 };
2475 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2476 dialog = dialog.set_directory(seed_dir);
2477 }
2478 }
2479 let result = if is_coverage {
2480 dialog
2481 .add_filter(
2482 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2483 &["info", "lcov", "xml", "json"],
2484 )
2485 .pick_file()
2486 } else {
2487 dialog.pick_folder()
2488 };
2489
2490 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2491 win_dialog_focus::detach_from_foreground(fg_tid);
2492
2493 result
2494 })
2495 .await
2496 .unwrap_or(None);
2497
2498 Json(PickDirectoryResponse {
2499 selected_path: picked.as_ref().map(|p| display_path(p)),
2500 cancelled: picked.is_none(),
2501 })
2502 .into_response()
2503}
2504
2505#[cfg(not(feature = "native-dialog"))]
2506async fn pick_directory_handler(
2507 State(_state): State<AppState>,
2508 Query(_query): Query<PickDirectoryQuery>,
2509) -> Response {
2510 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2511}
2512
2513#[cfg(feature = "native-dialog")]
2514async fn pick_file_handler(State(state): State<AppState>) -> Response {
2515 if state.server_mode {
2516 return StatusCode::NOT_FOUND.into_response();
2517 }
2518 if std::env::var("SLOC_HEADLESS").is_ok() {
2519 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2520 .into_response();
2521 }
2522 let picked = tokio::task::spawn_blocking(|| {
2523 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2524 let fg_tid = win_dialog_focus::attach_to_foreground();
2525 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2526 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2527
2528 let result = rfd::FileDialog::new()
2529 .set_title("Select HTML report")
2530 .add_filter("HTML report", &["html"])
2531 .pick_file();
2532
2533 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2534 win_dialog_focus::detach_from_foreground(fg_tid);
2535
2536 result
2537 })
2538 .await
2539 .unwrap_or(None);
2540 Json(PickDirectoryResponse {
2541 selected_path: picked.as_ref().map(|p| display_path(p)),
2542 cancelled: picked.is_none(),
2543 })
2544 .into_response()
2545}
2546
2547#[cfg(not(feature = "native-dialog"))]
2548async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2549 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2550}
2551
2552fn is_upload_tmp_path(path: &Path) -> bool {
2557 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2558 path.starts_with(&upload_root)
2559}
2560
2561fn is_sample_path(path: &Path) -> bool {
2564 let root = workspace_root();
2565 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2566}
2567
2568fn upload_base_dir() -> PathBuf {
2570 std::env::temp_dir().join("oxide-sloc-uploads")
2571}
2572
2573fn upload_staging_path(id: &str) -> PathBuf {
2575 upload_base_dir().join(id)
2576}
2577
2578#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2582 const MAX_FILES: usize = 50_000;
2583 if body.files.is_empty() {
2584 return Err((
2585 StatusCode::BAD_REQUEST,
2586 Json(serde_json::json!({"error": "No files received"})),
2587 )
2588 .into_response());
2589 }
2590 if body.files.len() > MAX_FILES {
2591 return Err((
2592 StatusCode::PAYLOAD_TOO_LARGE,
2593 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2594 )
2595 .into_response());
2596 }
2597 Ok(())
2598}
2599
2600fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2603 match id {
2604 Some(id)
2605 if !id.is_empty()
2606 && id.len() <= 36
2607 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2608 {
2609 (id.to_string(), upload_staging_path(id))
2610 }
2611 _ => {
2612 let new_id = uuid::Uuid::new_v4().to_string();
2613 let staging = upload_staging_path(&new_id);
2614 (new_id, staging)
2615 }
2616 }
2617}
2618
2619#[allow(clippy::result_large_err)]
2624async fn stage_decoded_entry(
2625 entry: &UploadedFile,
2626 staging: &Path,
2627 total_bytes: &mut usize,
2628 project_root: &mut Option<PathBuf>,
2629) -> Result<(), Response> {
2630 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2631
2632 let Ok(data) = base64::Engine::decode(
2633 &base64::engine::general_purpose::STANDARD,
2634 entry.content.as_bytes(),
2635 ) else {
2636 return Ok(());
2637 };
2638
2639 *total_bytes += data.len();
2640 if *total_bytes > MAX_TOTAL_BYTES {
2641 return Err((
2642 StatusCode::PAYLOAD_TOO_LARGE,
2643 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2644 )
2645 .into_response());
2646 }
2647
2648 let rel = std::path::Path::new(&entry.path);
2649 if project_root.is_none() {
2650 if let Some(first) = rel.components().next() {
2651 *project_root = Some(staging.join(first.as_os_str()));
2652 }
2653 }
2654
2655 let dest = staging.join(rel);
2656 if let Some(parent) = dest.parent() {
2657 if tokio::fs::create_dir_all(parent).await.is_err() {
2658 return Err((
2659 StatusCode::INTERNAL_SERVER_ERROR,
2660 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2661 )
2662 .into_response());
2663 }
2664 }
2665
2666 if tokio::fs::write(&dest, &data).await.is_err() {
2667 return Err((
2668 StatusCode::INTERNAL_SERVER_ERROR,
2669 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2670 )
2671 .into_response());
2672 }
2673
2674 Ok(())
2675}
2676
2677async fn write_upload_files(
2681 files: &[UploadedFile],
2682 staging: &Path,
2683 upload_id: &str,
2684) -> Result<(usize, Option<PathBuf>), Response> {
2685 let mut total_bytes: usize = 0;
2686 let mut project_root: Option<PathBuf> = None;
2687
2688 for entry in files {
2689 let rel = std::path::Path::new(&entry.path);
2690 if rel
2691 .components()
2692 .any(|c| matches!(c, std::path::Component::ParentDir))
2693 {
2694 let _ = tokio::fs::remove_dir_all(staging).await;
2696 tracing::warn!(
2697 event = "upload_path_traversal",
2698 upload_id = %upload_id,
2699 path = %entry.path,
2700 "Upload rejected: path traversal component detected"
2701 );
2702 return Err((
2703 StatusCode::BAD_REQUEST,
2704 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2705 )
2706 .into_response());
2707 }
2708
2709 if let Err(resp) =
2710 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2711 {
2712 let _ = tokio::fs::remove_dir_all(staging).await;
2713 return Err(resp);
2714 }
2715 }
2716
2717 Ok((files.len(), project_root))
2718}
2719
2720fn parse_tarball_size_caps() -> (u64, u64) {
2723 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2724 .ok()
2725 .and_then(|v| v.parse().ok())
2726 .unwrap_or(2048_u64)
2727 * 1024
2728 * 1024;
2729 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2730 .ok()
2731 .and_then(|v| v.parse().ok())
2732 .unwrap_or(10_240_u64)
2733 * 1024
2734 * 1024;
2735 (compressed, decompressed)
2736}
2737
2738fn tarball_http_body_limit_bytes() -> usize {
2742 std::env::var("SLOC_MAX_TARBALL_MB")
2743 .ok()
2744 .and_then(|v| v.parse::<usize>().ok())
2745 .unwrap_or(2048)
2746 .saturating_mul(1024 * 1024)
2747}
2748
2749#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2754 body: axum::body::Body,
2755 dest_path: &Path,
2756 max_bytes: u64,
2757) -> Result<u64, Response> {
2758 use http_body_util::BodyExt as _;
2759 use tokio::io::AsyncWriteExt as _;
2760
2761 let mut file = match tokio::fs::File::create(dest_path).await {
2762 Ok(f) => f,
2763 Err(e) => {
2764 tracing::error!(
2765 event = "upload_io_error",
2766 "failed to create tarball temp file: {e}"
2767 );
2768 return Err((
2769 StatusCode::INTERNAL_SERVER_ERROR,
2770 Json(serde_json::json!({"error": "Upload initialization failed"})),
2771 )
2772 .into_response());
2773 }
2774 };
2775
2776 let mut body = body;
2777 let mut written: u64 = 0;
2778 loop {
2779 match body.frame().await {
2780 None => break,
2781 Some(Err(e)) => {
2782 let _ = tokio::fs::remove_file(dest_path).await;
2783 return Err((
2784 StatusCode::BAD_REQUEST,
2785 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2786 )
2787 .into_response());
2788 }
2789 Some(Ok(frame)) => {
2790 if let Ok(data) = frame.into_data() {
2791 written += data.len() as u64;
2792 if written > max_bytes {
2793 let _ = tokio::fs::remove_file(dest_path).await;
2794 return Err((
2795 StatusCode::PAYLOAD_TOO_LARGE,
2796 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2797 )
2798 .into_response());
2799 }
2800 if let Err(e) = file.write_all(&data).await {
2801 let _ = tokio::fs::remove_file(dest_path).await;
2802 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2803 return Err((
2804 StatusCode::INTERNAL_SERVER_ERROR,
2805 Json(serde_json::json!({"error": "Upload write failed"})),
2806 )
2807 .into_response());
2808 }
2809 }
2810 }
2811 }
2812 }
2813 drop(file);
2814 Ok(written)
2815}
2816
2817#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2822 tarball_path: &Path,
2823 staging: &Path,
2824 max_decompressed_bytes: u64,
2825) -> Result<(), Response> {
2826 let staging_clone = staging.to_path_buf();
2827 let tarball_clone = tarball_path.to_path_buf();
2828 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2829 let file = std::fs::File::open(&tarball_clone)?;
2830 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2831 let limited = SizeLimitReader {
2832 inner: gz,
2833 remaining: max_decompressed_bytes,
2834 };
2835 let mut archive = tar::Archive::new(limited);
2836 archive.set_overwrite(true);
2837 archive.set_preserve_permissions(false);
2838 std::fs::create_dir_all(&staging_clone)?;
2839 archive.unpack(&staging_clone)?;
2840 Ok(())
2841 })
2842 .await;
2843 let _ = tokio::fs::remove_file(tarball_path).await;
2844
2845 match extract_result {
2846 Ok(Ok(())) => Ok(()),
2847 Ok(Err(e)) => {
2848 let _ = tokio::fs::remove_dir_all(staging).await;
2849 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2850 tracing::warn!(
2851 event = "upload_extract_error",
2852 "tarball extraction failed: {e:#}"
2853 );
2854 let (status, msg) = if is_size_limit {
2855 (
2856 StatusCode::PAYLOAD_TOO_LARGE,
2857 "Archive exceeds the decompressed size limit",
2858 )
2859 } else {
2860 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2861 };
2862 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2863 }
2864 Err(e) => {
2865 let _ = tokio::fs::remove_dir_all(staging).await;
2866 tracing::error!(
2867 event = "upload_extract_panic",
2868 "tarball extraction task panicked: {e}"
2869 );
2870 Err((
2871 StatusCode::INTERNAL_SERVER_ERROR,
2872 Json(serde_json::json!({"error": "Archive extraction failed"})),
2873 )
2874 .into_response())
2875 }
2876 }
2877}
2878
2879async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2883 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2884 let first = entries.next_entry().await.ok()??;
2885 if !first.path().is_dir() {
2886 return None;
2887 }
2888 if entries.next_entry().await.unwrap_or(None).is_some() {
2889 return None;
2890 }
2891 Some(first.path())
2892}
2893
2894#[derive(Deserialize)]
2901struct UploadDirRequest {
2902 files: Vec<UploadedFile>,
2903 upload_id: Option<String>,
2906}
2907
2908#[derive(Deserialize)]
2909struct UploadedFile {
2910 path: String,
2912 content: String,
2914}
2915
2916async fn upload_directory_handler(
2926 State(state): State<AppState>,
2927 Json(body): Json<UploadDirRequest>,
2928) -> Response {
2929 if !state.server_mode {
2930 return StatusCode::NOT_FOUND.into_response();
2931 }
2932 if let Err(resp) = validate_upload_dir_request(&body) {
2933 return resp;
2934 }
2935 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2938 match write_upload_files(&body.files, &staging, &upload_id).await {
2939 Ok((file_count, project_root)) => {
2940 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2941 Json(serde_json::json!({
2942 "tmp_path": scan_root.to_string_lossy(),
2943 "file_count": file_count,
2944 "upload_id": upload_id.clone()
2945 }))
2946 .into_response()
2947 }
2948 Err(resp) => resp,
2949 }
2950}
2951
2952#[derive(Deserialize)]
2954struct UploadFileRequest {
2955 filename: String,
2957 content: String,
2959}
2960
2961async fn upload_file_handler(
2967 State(state): State<AppState>,
2968 Json(body): Json<UploadFileRequest>,
2969) -> Response {
2970 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2973 return StatusCode::NOT_FOUND.into_response();
2974 }
2975
2976 let Ok(data) = base64::Engine::decode(
2977 &base64::engine::general_purpose::STANDARD,
2978 body.content.as_bytes(),
2979 ) else {
2980 return (
2981 StatusCode::BAD_REQUEST,
2982 Json(serde_json::json!({"error": "Invalid base64 content"})),
2983 )
2984 .into_response();
2985 };
2986
2987 if data.len() > MAX_FILE_BYTES {
2988 return (
2989 StatusCode::PAYLOAD_TOO_LARGE,
2990 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2991 )
2992 .into_response();
2993 }
2994
2995 let filename = std::path::Path::new(&body.filename)
2997 .file_name()
2998 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2999
3000 let upload_id = uuid::Uuid::new_v4();
3001 let staging = std::env::temp_dir()
3002 .join("oxide-sloc-uploads")
3003 .join(upload_id.to_string());
3004
3005 if tokio::fs::create_dir_all(&staging).await.is_err() {
3006 return (
3007 StatusCode::INTERNAL_SERVER_ERROR,
3008 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3009 )
3010 .into_response();
3011 }
3012
3013 let dest = staging.join(&filename);
3014 if tokio::fs::write(&dest, &data).await.is_err() {
3015 let _ = tokio::fs::remove_dir_all(&staging).await;
3016 return (
3017 StatusCode::INTERNAL_SERVER_ERROR,
3018 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3019 )
3020 .into_response();
3021 }
3022
3023 Json(serde_json::json!({
3024 "tmp_path": dest.to_string_lossy(),
3025 "upload_id": upload_id.to_string()
3026 }))
3027 .into_response()
3028}
3029
3030struct SizeLimitReader<R> {
3046 inner: R,
3047 remaining: u64,
3048}
3049impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3050 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3051 if self.remaining == 0 {
3052 return Err(std::io::Error::other("decompressed size limit exceeded"));
3053 }
3054 let n = self.inner.read(buf)?;
3055 self.remaining = self.remaining.saturating_sub(n as u64);
3056 Ok(n)
3057 }
3058}
3059
3060async fn upload_tarball_handler(
3061 State(state): State<AppState>,
3062 request: axum::extract::Request,
3063) -> Response {
3064 if !state.server_mode {
3065 return StatusCode::NOT_FOUND.into_response();
3066 }
3067
3068 let upload_id = uuid::Uuid::new_v4().to_string();
3069 let upload_base = upload_base_dir();
3070 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3071 let staging = upload_staging_path(&upload_id);
3072 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3073
3074 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3075 tracing::error!(
3076 event = "upload_io_error",
3077 "failed to create upload base dir: {e}"
3078 );
3079 return (
3080 StatusCode::INTERNAL_SERVER_ERROR,
3081 Json(serde_json::json!({"error": "Upload initialization failed"})),
3082 )
3083 .into_response();
3084 }
3085
3086 let compressed_bytes =
3088 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3089 Ok(n) => n,
3090 Err(resp) => return resp,
3091 };
3092
3093 if let Err(resp) =
3095 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3096 {
3097 return resp;
3098 }
3099
3100 let scan_root = find_single_top_dir(&staging)
3105 .await
3106 .unwrap_or_else(|| staging.clone());
3107
3108 let original_bytes = tokio::task::spawn_blocking({
3110 let p = scan_root.clone();
3111 move || dir_size_bytes(&p)
3112 })
3113 .await
3114 .unwrap_or(0);
3115
3116 Json(serde_json::json!({
3117 "tmp_path": scan_root.to_string_lossy(),
3118 "upload_id": upload_id,
3119 "compressed_bytes": compressed_bytes,
3120 "original_bytes": original_bytes,
3121 }))
3122 .into_response()
3123}
3124
3125#[derive(Deserialize)]
3126struct LocateReportForm {
3127 file_path: String,
3128 #[serde(default)]
3129 redirect_url: Option<String>,
3130 #[serde(default)]
3131 expected_run_id: Option<String>,
3132}
3133
3134fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3136 let html = ErrorTemplate {
3137 message: message.into(),
3138 last_report_url: Some("/view-reports".to_string()),
3139 last_report_label: Some("View Reports".to_string()),
3140 run_id: None,
3141 error_code: None,
3142 csp_nonce: csp_nonce.to_owned(),
3143 version: env!("CARGO_PKG_VERSION"),
3144 }
3145 .render()
3146 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3147 Html(html).into_response()
3148}
3149
3150fn registry_entry_from_run(
3152 run: &AnalysisRun,
3153 json_path: PathBuf,
3154 html_path: PathBuf,
3155) -> RegistryEntry {
3156 let project_label = run.input_roots.first().map_or_else(
3157 || "Unknown Project".to_string(),
3158 |r| sanitize_project_label(r),
3159 );
3160 RegistryEntry {
3161 run_id: run.tool.run_id.clone(),
3162 timestamp_utc: run.tool.timestamp_utc,
3163 project_label,
3164 input_roots: run.input_roots.clone(),
3165 json_path: Some(json_path),
3166 html_path: Some(html_path),
3167 pdf_path: None,
3168 summary: ScanSummarySnapshot::from(&run.summary_totals),
3169 csv_path: None,
3170 xlsx_path: None,
3171 git_branch: None,
3172 git_commit: None,
3173 git_commit_long: None,
3174 git_author: None,
3175 git_tags: None,
3176 git_nearest_tag: None,
3177 git_commit_date: None,
3178 }
3179}
3180
3181pub(crate) async fn register_artifacts_in_registry(
3184 state: &AppState,
3185 label: &str,
3186 run: &AnalysisRun,
3187 artifacts: &RunArtifacts,
3188) {
3189 let Some(json_path) = artifacts.json_path.clone() else {
3190 return;
3191 };
3192 let Some(html_path) = artifacts.html_path.clone() else {
3193 return;
3194 };
3195 let mut entry = registry_entry_from_run(run, json_path, html_path);
3196 entry.project_label = label.to_owned();
3197 let mut reg = state.registry.lock().await;
3198 reg.add_entry(entry);
3199 let _ = reg.save(&state.registry_path);
3200}
3201
3202fn is_html_report_file(p: &Path) -> bool {
3203 p.is_file()
3204 && p.extension()
3205 .and_then(|x| x.to_str())
3206 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3207 && p.file_name()
3208 .and_then(|n| n.to_str())
3209 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3210}
3211
3212fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3213 fs::read_dir(dir)
3214 .ok()?
3215 .flatten()
3216 .map(|e| e.path())
3217 .find(|p| is_html_report_file(p))
3218}
3219
3220fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3221 if let Some(f) = find_html_report_in_dir(dir) {
3222 return Some(f);
3223 }
3224 if let Ok(rd) = fs::read_dir(dir) {
3225 for entry in rd.flatten() {
3226 let sub = entry.path();
3227 if sub.is_dir() {
3228 if let Some(f) = find_html_report_in_dir(&sub) {
3229 return Some(f);
3230 }
3231 }
3232 }
3233 }
3234 None
3235}
3236
3237#[allow(clippy::result_large_err)]
3242fn validate_locate_request(
3243 state: &AppState,
3244 file_path: &str,
3245 csp_nonce: &str,
3246) -> Result<(PathBuf, PathBuf), Response> {
3247 let raw = PathBuf::from(file_path);
3248
3249 let html_path = if raw.is_dir() {
3251 let found = find_html_report_in_tree(&raw);
3252 match found {
3253 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3254 None => {
3255 return Err(locate_report_error(
3256 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3257 the folder that contains your scan output (result_*.html or report_*.html).",
3258 csp_nonce,
3259 ));
3260 }
3261 }
3262 } else {
3263 let file_ext = raw
3264 .extension()
3265 .and_then(|e| e.to_str())
3266 .unwrap_or("")
3267 .to_ascii_lowercase();
3268 if file_ext != "html" {
3269 return Err(locate_report_error(
3270 "Please select the scan output folder, or an .html report file directly.",
3271 csp_nonce,
3272 ));
3273 }
3274 match fs::canonicalize(&raw) {
3275 Ok(p) => strip_unc_prefix(p),
3276 Err(_) => {
3277 return Err(locate_report_error(
3278 "Report file not found or path is invalid.",
3279 csp_nonce,
3280 ));
3281 }
3282 }
3283 };
3284
3285 if state.server_mode {
3286 let output_root = resolve_output_root(None);
3287 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3288 if !html_path.starts_with(&canonical_root) {
3289 return Err(locate_report_error(
3290 "Report file must be within the configured output directory.",
3291 csp_nonce,
3292 ));
3293 }
3294 }
3295 let parent = match html_path.parent() {
3296 Some(p) => p.to_path_buf(),
3297 None => {
3298 return Err(locate_report_error(
3299 "Report file has no parent directory.",
3300 csp_nonce,
3301 ));
3302 }
3303 };
3304 Ok((html_path, parent))
3305}
3306
3307fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3309 if want_json {
3310 (
3311 StatusCode::UNPROCESSABLE_ENTITY,
3312 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3313 )
3314 .into_response()
3315 } else {
3316 locate_report_error(msg, csp_nonce)
3317 }
3318}
3319
3320fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3322 if want_json {
3323 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3324 } else {
3325 axum::response::Redirect::to(redirect).into_response()
3326 }
3327}
3328
3329fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3332 for jpath in candidates {
3333 if let Ok(run) = read_json(jpath) {
3334 if expected.is_empty() || run.tool.run_id == expected {
3335 return Some((jpath.clone(), run.tool.run_id));
3336 }
3337 }
3338 }
3339 None
3340}
3341
3342fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3343 html_path
3344 .parent()
3345 .and_then(|p| p.parent())
3346 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3347}
3348
3349fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3350 let mut hits = collect_result_json_candidates(scan_root);
3351 if hits.is_empty() {
3352 hits = collect_result_json_candidates(parent);
3353 }
3354 hits.sort();
3355 hits
3356}
3357
3358#[allow(clippy::too_many_lines)]
3359async fn locate_report_handler(
3360 State(state): State<AppState>,
3361 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3362 headers: axum::http::HeaderMap,
3363 Form(form): Form<LocateReportForm>,
3364) -> impl IntoResponse {
3365 let want_json = headers
3366 .get(axum::http::header::ACCEPT)
3367 .and_then(|v| v.to_str().ok())
3368 .is_some_and(|v| v.contains("application/json"));
3369
3370 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3371 Ok(v) => v,
3372 Err(resp) => {
3373 if want_json {
3374 return locate_handler_err(
3375 true,
3376 "No HTML report file found in the selected folder. \
3377 Make sure you selected the folder that contains your \
3378 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3379 .to_string(),
3380 &csp_nonce,
3381 );
3382 }
3383 return resp;
3384 }
3385 };
3386
3387 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3390 let scan_root: &Path = &scan_root_owned;
3391 let json_candidates = gather_json_candidates(scan_root, &parent);
3392
3393 let expected_run_id = form
3395 .expected_run_id
3396 .as_deref()
3397 .unwrap_or("")
3398 .trim()
3399 .to_string();
3400
3401 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3402
3403 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3405 let actual = json_candidates
3406 .iter()
3407 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3408 .unwrap_or_else(|| "unknown".to_string());
3409 return locate_handler_err(
3410 want_json,
3411 format!(
3412 "This folder contains a different scan.\n\n\
3413 Expected run ID : {expected_run_id}\n\
3414 Found run ID : {actual}\n\n\
3415 Please select the folder that contains the correct scan output."
3416 ),
3417 &csp_nonce,
3418 );
3419 }
3420
3421 let safe_redirect = form
3422 .redirect_url
3423 .as_deref()
3424 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3425 .unwrap_or("/view-reports?linked=1")
3426 .to_string();
3427
3428 let mut reg = state.registry.lock().await;
3429
3430 if let Some((json_path, run_id)) = matched_json {
3431 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3433 entry.html_path = Some(html_path);
3434 entry.json_path = Some(json_path);
3435 let _ = reg.save(&state.registry_path);
3436 drop(reg);
3437 state.artifacts.lock().await.remove(&run_id);
3439 return redirect_or_json_ok(want_json, &safe_redirect);
3440 }
3441 match read_json(&json_path) {
3443 Ok(run) => {
3444 let entry = registry_entry_from_run(&run, json_path, html_path);
3445 reg.add_entry(entry);
3446 let _ = reg.save(&state.registry_path);
3447 drop(reg);
3448 state.artifacts.lock().await.remove(&run_id);
3449 return redirect_or_json_ok(want_json, &safe_redirect);
3450 }
3451 Err(e) => {
3452 drop(reg);
3453 return locate_handler_err(
3454 want_json,
3455 format!(
3456 "Found the scan folder but could not parse the result JSON.\n\n\
3457 The file may have been saved by an older version of OxideSLOC. \
3458 Re-running the analysis will create a fresh, compatible record.\n\n\
3459 Error: {e}"
3460 ),
3461 &csp_nonce,
3462 );
3463 }
3464 }
3465 }
3466
3467 if let Some(entry) = reg
3469 .entries
3470 .iter_mut()
3471 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3472 {
3473 entry.html_path = Some(html_path.clone());
3474 let _ = reg.save(&state.registry_path);
3475 drop(reg);
3476 state.artifacts.lock().await.remove(&expected_run_id);
3477 return redirect_or_json_ok(want_json, &safe_redirect);
3478 }
3479
3480 drop(reg);
3481 let hint = if state.server_mode {
3482 String::new()
3483 } else {
3484 format!(
3485 "\n\nSearched folder : {}\nHTML found : {}",
3486 scan_root.display(),
3487 html_path.display()
3488 )
3489 };
3490 locate_handler_err(
3491 want_json,
3492 format!(
3493 "Could not link this report.\n\n\
3494 No result_*.json was found in the selected folder. \
3495 Make sure you selected the top-level scan output folder \
3496 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3497 ),
3498 &csp_nonce,
3499 )
3500}
3501
3502fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3504 fs::read_dir(dir)
3505 .ok()?
3506 .flatten()
3507 .map(|e| e.path())
3508 .find(|p| {
3509 p.is_file()
3510 && p.file_stem()
3511 .and_then(|n| n.to_str())
3512 .is_some_and(|n| n.starts_with("result"))
3513 && p.extension()
3514 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3515 })
3516}
3517
3518#[derive(Deserialize)]
3519struct LocateReportsDirForm {
3520 folder_path: String,
3521}
3522
3523#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3525 State(state): State<AppState>,
3526 Form(form): Form<LocateReportsDirForm>,
3527) -> impl IntoResponse {
3528 if state.server_mode {
3529 return StatusCode::NOT_FOUND.into_response();
3530 }
3531 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3532 Ok(p) => strip_unc_prefix(p),
3533 Err(_) => {
3534 return axum::response::Redirect::to(
3535 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3536 )
3537 .into_response();
3538 }
3539 };
3540 if !folder.is_dir() {
3541 return axum::response::Redirect::to(
3542 "/view-reports?error=Selected+path+is+not+a+directory.",
3543 )
3544 .into_response();
3545 }
3546
3547 let candidates = collect_result_json_candidates(&folder);
3548
3549 if candidates.is_empty() {
3550 return axum::response::Redirect::to(
3551 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3552 )
3553 .into_response();
3554 }
3555
3556 let mut linked_count: usize = 0;
3557 let mut reg = state.registry.lock().await;
3558 for json_path in candidates {
3559 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3560 continue;
3561 };
3562 if is_dir_already_registered(®, &parent) {
3563 continue;
3564 }
3565 let Some(entry) = build_registry_entry_from_json(json_path) else {
3566 continue;
3567 };
3568 reg.add_entry(entry);
3569 linked_count += 1;
3570 }
3571 let _ = reg.save(&state.registry_path);
3572 drop(reg);
3573
3574 if linked_count == 0 {
3575 return axum::response::Redirect::to(
3576 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3577 )
3578 .into_response();
3579 }
3580 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3581}
3582
3583#[derive(Deserialize)]
3584struct RelocateScanForm {
3585 run_id: String,
3586 folder_path: String,
3587 redirect_url: String,
3588}
3589
3590fn relocate_folder_err(
3593 want_json: bool,
3594 status: StatusCode,
3595 msg: &str,
3596 run_id: &str,
3597 folder_hint: &str,
3598 redirect_url: &str,
3599 csp_nonce: &str,
3600) -> Response {
3601 if want_json {
3602 (
3603 status,
3604 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3605 )
3606 .into_response()
3607 } else {
3608 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3609 }
3610}
3611
3612#[allow(clippy::too_many_lines)]
3613async fn relocate_scan_handler(
3614 State(state): State<AppState>,
3615 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3616 headers: axum::http::HeaderMap,
3617 Form(form): Form<RelocateScanForm>,
3618) -> impl IntoResponse {
3619 let want_json = headers
3620 .get(axum::http::header::ACCEPT)
3621 .and_then(|v| v.to_str().ok())
3622 .is_some_and(|v| v.contains("application/json"));
3623 if state.server_mode {
3624 return StatusCode::NOT_FOUND.into_response();
3625 }
3626
3627 let run_id = form.run_id.trim().to_string();
3628 let redirect_url = form.redirect_url.trim().to_string();
3629
3630 let run_exists = {
3631 let reg = state.registry.lock().await;
3632 reg.find_by_run_id(&run_id).is_some()
3633 };
3634 if !run_exists {
3635 if want_json {
3636 return (
3637 StatusCode::NOT_FOUND,
3638 axum::Json(serde_json::json!({
3639 "ok": false,
3640 "message": format!("Run ID '{run_id}' not found in registry.")
3641 })),
3642 )
3643 .into_response();
3644 }
3645 let html = ErrorTemplate {
3646 message: format!("Run ID '{run_id}' not found in registry."),
3647 last_report_url: Some("/compare-scans".to_string()),
3648 last_report_label: Some("Compare Scans".to_string()),
3649 run_id: Some(run_id.clone()),
3650 error_code: Some(404),
3651 csp_nonce: csp_nonce.clone(),
3652 version: env!("CARGO_PKG_VERSION"),
3653 }
3654 .render()
3655 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3656 return Html(html).into_response();
3657 }
3658
3659 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3660 Ok(p) => strip_unc_prefix(p),
3661 Err(_) => {
3662 return relocate_folder_err(
3663 want_json,
3664 StatusCode::UNPROCESSABLE_ENTITY,
3665 "Folder not found or path is invalid.",
3666 &run_id,
3667 form.folder_path.trim(),
3668 &redirect_url,
3669 &csp_nonce,
3670 );
3671 }
3672 };
3673 if !folder.is_dir() {
3674 return relocate_folder_err(
3675 want_json,
3676 StatusCode::UNPROCESSABLE_ENTITY,
3677 "Selected path is not a directory.",
3678 &run_id,
3679 &folder.display().to_string(),
3680 &redirect_url,
3681 &csp_nonce,
3682 );
3683 }
3684
3685 let json_candidates = find_result_files_by_ext(&folder, "json");
3686 if json_candidates.is_empty() {
3687 let msg = format!(
3688 "No result JSON files found in the selected folder.\nSearched: {}",
3689 folder.display()
3690 );
3691 return relocate_folder_err(
3692 want_json,
3693 StatusCode::UNPROCESSABLE_ENTITY,
3694 &msg,
3695 &run_id,
3696 &folder.display().to_string(),
3697 &redirect_url,
3698 &csp_nonce,
3699 );
3700 }
3701
3702 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3703 let msg = format!(
3704 "No matching scan found in the selected folder.\n\
3705 The JSON files present do not contain run ID: {run_id}\n\
3706 Searched: {}",
3707 folder.display()
3708 );
3709 return relocate_folder_err(
3710 want_json,
3711 StatusCode::UNPROCESSABLE_ENTITY,
3712 &msg,
3713 &run_id,
3714 &folder.display().to_string(),
3715 &redirect_url,
3716 &csp_nonce,
3717 );
3718 };
3719
3720 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3721 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3722 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3723
3724 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3725 redirect_url
3726 } else {
3727 "/compare-scans".to_string()
3728 };
3729 redirect_or_json_ok(want_json, &safe_redirect)
3730}
3731
3732fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3733 let mut out = Vec::new();
3734 collect_scan_files_by_ext(folder, ext, &mut out);
3735 if let Ok(rd) = fs::read_dir(folder) {
3736 for entry in rd.flatten() {
3737 let sub = entry.path();
3738 if sub.is_dir() {
3739 collect_scan_files_by_ext(&sub, ext, &mut out);
3740 }
3741 }
3742 }
3743 out
3744}
3745
3746fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3747 let Ok(rd) = fs::read_dir(dir) else { return };
3748 for entry in rd.flatten() {
3749 let p = entry.path();
3750 if p.is_file()
3751 && p.file_stem()
3752 .and_then(|n| n.to_str())
3753 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3754 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3755 {
3756 out.push(p);
3757 }
3758 }
3759}
3760
3761fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3762 candidates
3763 .iter()
3764 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3765 .cloned()
3766}
3767
3768fn output_folder_hint(json_path: &std::path::Path) -> String {
3773 let Some(direct_parent) = json_path.parent() else {
3774 return String::new();
3775 };
3776 let parent_name = direct_parent
3777 .file_name()
3778 .and_then(|n| n.to_str())
3779 .unwrap_or("");
3780 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3781 direct_parent.parent().map_or_else(
3782 || direct_parent.display().to_string(),
3783 |p| p.display().to_string(),
3784 )
3785 } else {
3786 direct_parent.display().to_string()
3787 }
3788}
3789
3790async fn update_run_file_paths(
3791 state: &AppState,
3792 run_id: &str,
3793 json_path: PathBuf,
3794 html_path: Option<PathBuf>,
3795 pdf_path: Option<PathBuf>,
3796) {
3797 {
3798 let mut reg = state.registry.lock().await;
3799 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3800 entry.json_path = Some(json_path.clone());
3801 if let Some(ref hp) = html_path {
3802 entry.html_path = Some(hp.clone());
3803 }
3804 if let Some(ref pp) = pdf_path {
3805 entry.pdf_path = Some(pp.clone());
3806 }
3807 }
3808 let _ = reg.save(&state.registry_path);
3809 }
3810 {
3813 let mut map = state.artifacts.lock().await;
3814 if let Some(arts) = map.get_mut(run_id) {
3815 arts.json_path = Some(json_path);
3816 if let Some(hp) = html_path {
3817 arts.html_path = Some(hp);
3818 }
3819 if let Some(pp) = pdf_path {
3820 arts.pdf_path = Some(pp);
3821 }
3822 }
3823 }
3824}
3825
3826fn missing_scan_relocate_response(
3827 message: &str,
3828 run_id: &str,
3829 folder_hint: &str,
3830 redirect_url: &str,
3831 server_mode: bool,
3832 csp_nonce: &str,
3833) -> axum::response::Response {
3834 let html = RelocateScanTemplate {
3835 message: message.to_string(),
3836 run_id: run_id.to_string(),
3837 folder_hint: folder_hint.to_string(),
3838 redirect_url: redirect_url.to_string(),
3839 server_mode,
3840 csp_nonce: csp_nonce.to_owned(),
3841 version: env!("CARGO_PKG_VERSION"),
3842 }
3843 .render()
3844 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3845 (StatusCode::NOT_FOUND, Html(html)).into_response()
3846}
3847
3848fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3852 fs::read_dir(dir)
3853 .ok()?
3854 .flatten()
3855 .map(|e| e.path())
3856 .find(|p| {
3857 p.is_file()
3858 && p.extension()
3859 .and_then(|e| e.to_str())
3860 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3861 })
3862}
3863
3864fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3868 let mut out = Vec::new();
3869 if let Some(j) = find_result_json_in_dir(sub) {
3870 out.push(j);
3871 }
3872 let json_sub = sub.join("json");
3873 if json_sub.is_dir() {
3874 if let Some(j) = find_result_json_in_dir(&json_sub) {
3875 out.push(j);
3876 }
3877 }
3878 out
3879}
3880
3881fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3882 let mut candidates = Vec::new();
3883 if let Some(j) = find_result_json_in_dir(folder) {
3884 candidates.push(j);
3885 }
3886 let Ok(dir_entries) = fs::read_dir(folder) else {
3887 return candidates;
3888 };
3889 for entry in dir_entries.flatten() {
3890 let sub = entry.path();
3891 if sub.is_dir() {
3892 candidates.extend(subdir_result_json_candidates(&sub));
3893 }
3894 }
3895 candidates
3896}
3897
3898fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3899 reg.entries.iter().any(|e| {
3900 let dir_match = e
3901 .json_path
3902 .as_ref()
3903 .and_then(|p| p.parent())
3904 .is_some_and(|p| p == parent)
3905 || e.html_path
3906 .as_ref()
3907 .and_then(|p| p.parent())
3908 .is_some_and(|p| p == parent);
3909 dir_match
3910 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3911 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3912 })
3913}
3914
3915fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3916 let json_dir = json_path.parent()?.to_path_buf();
3917 let (html_path, pdf_path, csv_path, xlsx_path) =
3920 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3921 let scan_root = json_dir.parent()?;
3922 let html = find_html_report_in_dir(&scan_root.join("html"))
3923 .or_else(|| find_html_report_in_dir(scan_root));
3924 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3925 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3926 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3927 (html, pdf, csv, xlsx)
3928 } else {
3929 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3930 rd.flatten()
3931 .map(|e| e.path())
3932 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3933 });
3934 (html, None, None, None)
3935 };
3936 let run = read_json(&json_path).ok()?;
3937 let project_label = run.input_roots.first().map_or_else(
3938 || "Unknown Project".to_string(),
3939 |r| sanitize_project_label(r),
3940 );
3941 Some(RegistryEntry {
3942 run_id: run.tool.run_id.clone(),
3943 timestamp_utc: run.tool.timestamp_utc,
3944 project_label,
3945 input_roots: run.input_roots.clone(),
3946 json_path: Some(json_path),
3947 html_path,
3948 pdf_path,
3949 csv_path,
3950 xlsx_path,
3951 summary: ScanSummarySnapshot::from(&run.summary_totals),
3952 git_branch: run.git_branch.clone(),
3953 git_commit: run.git_commit_short.clone(),
3954 git_commit_long: run.git_commit_long.clone(),
3955 git_author: run.git_commit_author.clone(),
3956 git_tags: run.git_tags.clone(),
3957 git_nearest_tag: run.git_nearest_tag.clone(),
3958 git_commit_date: run.git_commit_date,
3959 })
3960}
3961
3962fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3965 let mut linked = 0usize;
3966 for json_path in collect_result_json_candidates(folder) {
3967 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3968 continue;
3969 };
3970 if is_dir_already_registered(reg, &parent) {
3971 continue;
3972 }
3973 let Some(entry) = build_registry_entry_from_json(json_path) else {
3974 continue;
3975 };
3976 reg.add_entry(entry);
3977 linked += 1;
3978 }
3979 linked
3980}
3981
3982async fn auto_scan_watched_dirs(state: &AppState) {
3984 let dirs: Vec<PathBuf> = {
3985 let wd = state.watched_dirs.lock().await;
3986 wd.dirs.clone()
3987 };
3988 {
3992 let output_root = resolve_output_root(None);
3993 let mut roots: Vec<PathBuf> = dirs.clone();
3994 if let Ok(canon) = fs::canonicalize(&output_root) {
3995 roots.push(strip_unc_prefix(canon));
3996 }
3997 roots.push(output_root);
3998 let mut reg = state.registry.lock().await;
3999 if reg.retain_under_roots(&roots) > 0 {
4000 let _ = reg.save(&state.registry_path);
4001 }
4002 }
4003 if dirs.is_empty() {
4004 return;
4005 }
4006 let mut reg = state.registry.lock().await;
4007 let mut total = 0usize;
4008 for dir in &dirs {
4009 if dir.is_dir() {
4010 total += scan_folder_into_registry(dir, &mut reg);
4011 }
4012 }
4013 if total > 0 {
4014 let _ = reg.save(&state.registry_path);
4015 }
4016}
4017
4018#[derive(Deserialize)]
4021struct WatchedDirForm {
4022 folder_path: String,
4023 #[serde(default = "default_redirect")]
4024 redirect_to: String,
4025}
4026
4027fn default_redirect() -> String {
4028 "/view-reports".to_string()
4029}
4030
4031#[derive(Deserialize)]
4032struct WatchedDirRefreshForm {
4033 #[serde(default = "default_redirect")]
4034 redirect_to: String,
4035}
4036
4037fn safe_redirect(dest: &str) -> &str {
4041 if dest.starts_with('/') {
4042 dest
4043 } else {
4044 "/"
4045 }
4046}
4047
4048async fn add_watched_dir_handler(
4051 State(state): State<AppState>,
4052 Form(form): Form<WatchedDirForm>,
4053) -> impl IntoResponse {
4054 if state.server_mode {
4055 return StatusCode::NOT_FOUND.into_response();
4056 }
4057 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4058 strip_unc_prefix(p)
4059 } else {
4060 let dest = format!(
4061 "{}?error=Folder+not+found+or+path+is+invalid.",
4062 safe_redirect(&form.redirect_to)
4063 );
4064 return axum::response::Redirect::to(&dest).into_response();
4065 };
4066 if !folder.is_dir() {
4067 let dest = format!(
4068 "{}?error=Selected+path+is+not+a+directory.",
4069 safe_redirect(&form.redirect_to)
4070 );
4071 return axum::response::Redirect::to(&dest).into_response();
4072 }
4073
4074 {
4076 let mut wd = state.watched_dirs.lock().await;
4077 wd.add(folder.clone());
4078 let _ = wd.save(&state.watched_dirs_path);
4079 }
4080
4081 let linked = {
4083 let mut reg = state.registry.lock().await;
4084 let n = scan_folder_into_registry(&folder, &mut reg);
4085 if n > 0 {
4086 let _ = reg.save(&state.registry_path);
4087 }
4088 n
4089 };
4090
4091 let dest = if linked > 0 {
4092 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4093 } else {
4094 format!(
4095 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4096 safe_redirect(&form.redirect_to)
4097 )
4098 };
4099 axum::response::Redirect::to(&dest).into_response()
4100}
4101
4102async fn remove_watched_dir_handler(
4103 State(state): State<AppState>,
4104 Form(form): Form<WatchedDirForm>,
4105) -> impl IntoResponse {
4106 if state.server_mode {
4107 return StatusCode::NOT_FOUND.into_response();
4108 }
4109 let folder = PathBuf::from(&form.folder_path);
4110 {
4111 let mut wd = state.watched_dirs.lock().await;
4112 wd.remove(&folder);
4113 let _ = wd.save(&state.watched_dirs_path);
4114 }
4115 {
4117 let mut reg = state.registry.lock().await;
4118 if reg.remove_entries_under(&folder) > 0 {
4119 let _ = reg.save(&state.registry_path);
4120 }
4121 }
4122 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4123}
4124
4125async fn refresh_watched_dirs_handler(
4126 State(state): State<AppState>,
4127 Form(form): Form<WatchedDirRefreshForm>,
4128) -> impl IntoResponse {
4129 if state.server_mode {
4130 return StatusCode::NOT_FOUND.into_response();
4131 }
4132 let dirs: Vec<PathBuf> = {
4133 let wd = state.watched_dirs.lock().await;
4134 wd.dirs.clone()
4135 };
4136 let mut total = 0usize;
4137 {
4138 let mut reg = state.registry.lock().await;
4139 reg.prune_stale();
4140 for dir in &dirs {
4141 if dir.is_dir() {
4142 total += scan_folder_into_registry(dir, &mut reg);
4143 }
4144 }
4145 let _ = reg.save(&state.registry_path);
4146 }
4147 let dest = if total > 0 {
4148 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4149 } else {
4150 safe_redirect(&form.redirect_to).to_owned()
4151 };
4152 axum::response::Redirect::to(&dest).into_response()
4153}
4154
4155#[derive(Debug, Deserialize)]
4156struct OpenPathQuery {
4157 path: Option<String>,
4158}
4159
4160fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4161 let mut ancestor = std::path::Path::new(raw);
4162 loop {
4163 match ancestor.parent() {
4164 Some(p) => {
4165 ancestor = p;
4166 if ancestor.is_dir() {
4167 break;
4168 }
4169 }
4170 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4171 }
4172 }
4173 Ok(ancestor.to_path_buf())
4174}
4175
4176async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4177 match tokio::fs::canonicalize(raw).await {
4178 Ok(canonical) if canonical.is_file() => canonical
4179 .parent()
4180 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4181 Ok(p.to_path_buf())
4182 }),
4183 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4184 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4185 Err(_) => find_existing_ancestor(raw),
4186 }
4187}
4188
4189async fn open_path_handler(
4190 State(state): State<AppState>,
4191 Query(query): Query<OpenPathQuery>,
4192) -> impl IntoResponse {
4193 if state.server_mode {
4194 return Json(serde_json::json!({
4195 "server_mode_disabled": true,
4196 "message": "Opening a path in the file manager is only available in local desktop mode."
4197 }))
4198 .into_response();
4199 }
4200 if std::env::var("SLOC_HEADLESS").is_ok() {
4202 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4203 }
4204 let raw = match query.path.as_deref() {
4205 Some(p) if !p.is_empty() => p,
4206 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4207 };
4208
4209 let target = match resolve_open_target(raw).await {
4213 Ok(p) => p,
4214 Err((code, msg)) => return (code, msg).into_response(),
4215 };
4216
4217 #[cfg(target_os = "windows")]
4218 win_dialog_focus::open_folder_foreground(target);
4219 #[cfg(target_os = "macos")]
4220 let _ = std::process::Command::new("open")
4221 .arg(&target)
4222 .stdout(Stdio::null())
4223 .stderr(Stdio::null())
4224 .spawn();
4225 #[cfg(target_os = "linux")]
4226 {
4227 let folder_name = target
4228 .file_name()
4229 .and_then(|n| n.to_str())
4230 .map(str::to_owned);
4231 let _ = std::process::Command::new("xdg-open")
4232 .arg(&target)
4233 .stdout(Stdio::null())
4234 .stderr(Stdio::null())
4235 .spawn();
4236 if let Some(name) = folder_name {
4240 std::thread::spawn(move || {
4241 std::thread::sleep(std::time::Duration::from_millis(800));
4242 let _ = std::process::Command::new("wmctrl")
4243 .args(["-a", &name])
4244 .stdout(Stdio::null())
4245 .stderr(Stdio::null())
4246 .spawn();
4247 });
4248 }
4249 }
4250
4251 Json(serde_json::json!({"ok": true})).into_response()
4252}
4253
4254async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4255 let (content_type, bytes): (&'static str, &'static [u8]) =
4256 match (folder.as_str(), file.as_str()) {
4257 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4258 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4259 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4260 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4261 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4262 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4263 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4264 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4265 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4266 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4267 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4268 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4269 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4270 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4271 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4272 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4273 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4274 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4275 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4276 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4277 _ => return StatusCode::NOT_FOUND.into_response(),
4278 };
4279 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4280}
4281
4282fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4287 let Ok(canonical) = fs::canonicalize(resolved) else {
4292 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4293 return Err(Html(
4294 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4295 ));
4296 }
4297 return Ok(());
4298 };
4299 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4301 return Ok(());
4302 }
4303 let config = &state.base_config;
4304 if config.discovery.allowed_scan_roots.is_empty() {
4305 return Err(Html(
4306 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4307 ));
4308 }
4309 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4310 fs::canonicalize(root)
4311 .ok()
4312 .is_some_and(|r| canonical.starts_with(&r))
4313 });
4314 if !allowed {
4315 return Err(Html(
4316 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4317 ));
4318 }
4319 Ok(())
4320}
4321
4322async fn preview_handler(
4323 State(state): State<AppState>,
4324 Query(query): Query<PreviewQuery>,
4325) -> impl IntoResponse {
4326 let raw_path = query
4327 .path
4328 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4329 let resolved = resolve_input_path(&raw_path);
4330
4331 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4335 return Html(
4336 r#"<div class="preview-error">Sample directory not available on this server.
4337 Enter a path to a project directory or upload files using Browse.</div>"#
4338 .to_string(),
4339 );
4340 }
4341
4342 if state.server_mode {
4343 if let Err(resp) = authorize_preview_path(&state, &resolved) {
4344 return resp;
4345 }
4346 }
4347
4348 let include_patterns = split_patterns(query.include_globs.as_deref());
4349 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4350
4351 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4352 Ok(html) => Html(html),
4353 Err(err) => Html(format!(
4354 r#"<div class="preview-error">Preview failed: {}</div>"#,
4355 escape_html(&err.to_string())
4356 )),
4357 }
4358}
4359
4360#[derive(Debug, Deserialize, Default)]
4361struct SuggestCoverageQuery {
4362 path: Option<String>,
4363}
4364
4365#[derive(Serialize)]
4366struct SuggestCoverageResponse {
4367 found: Option<String>,
4368 tool: Option<&'static str>,
4369 hint: Option<&'static str>,
4370}
4371
4372async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4373 const CANDIDATES: &[&str] = &[
4374 "coverage/lcov.info",
4376 "lcov.info",
4377 "target/llvm-cov/lcov.info",
4378 "target/coverage/lcov.info",
4379 "target/debug/coverage/lcov.info",
4380 "coverage/coverage.lcov",
4381 "build/coverage/lcov.info",
4382 "reports/lcov.info",
4383 "coverage.xml",
4385 "coverage/coverage.xml",
4386 "target/site/cobertura/coverage.xml",
4387 "build/reports/coverage/coverage.xml",
4388 "target/site/jacoco/jacoco.xml",
4390 "build/reports/jacoco/test/jacocoTestReport.xml",
4391 "build/reports/jacoco/jacocoTestReport.xml",
4392 "build/jacoco/jacoco.xml",
4393 "coverage.json",
4395 "coverage/coverage.json",
4396 ];
4397 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4398 let found = CANDIDATES
4399 .iter()
4400 .map(|rel| root.join(rel))
4401 .find(|p| p.is_file())
4402 .map(|p| display_path(&p));
4403
4404 let (tool, hint) = detect_coverage_tool(&root);
4405 Json(SuggestCoverageResponse { found, tool, hint })
4406}
4407
4408fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4411 if root.join("Cargo.toml").is_file() {
4412 return (
4413 Some("cargo-llvm-cov"),
4414 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4415 );
4416 }
4417 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4418 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4419 }
4420 if root.join("pom.xml").is_file() {
4421 return (Some("jacoco"), Some("mvn test jacoco:report"));
4422 }
4423 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4424 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4425 }
4426 (None, None)
4427}
4428
4429#[allow(clippy::result_large_err)]
4431fn validate_server_scan_path(
4432 config: &sloc_config::AppConfig,
4433 resolved_path: &Path,
4434 csp_nonce: &str,
4435) -> Result<(), Response> {
4436 if config.discovery.allowed_scan_roots.is_empty() {
4437 let template = ErrorTemplate {
4438 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4439 Set allowed_scan_roots in the server config to permit scanning."
4440 .to_string(),
4441 last_report_url: None,
4442 last_report_label: None,
4443 run_id: None,
4444 error_code: Some(403),
4445 csp_nonce: csp_nonce.to_owned(),
4446 version: env!("CARGO_PKG_VERSION"),
4447 };
4448 return Err((
4449 StatusCode::FORBIDDEN,
4450 Html(
4451 template
4452 .render()
4453 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4454 ),
4455 )
4456 .into_response());
4457 }
4458 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4463 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4464 "Scan path does not resolve to a real location");
4465 let template = ErrorTemplate {
4466 message: "The requested path could not be resolved to a real directory.".to_string(),
4467 last_report_url: None,
4468 last_report_label: None,
4469 run_id: None,
4470 error_code: Some(403),
4471 csp_nonce: csp_nonce.to_owned(),
4472 version: env!("CARGO_PKG_VERSION"),
4473 };
4474 return Err((
4475 StatusCode::FORBIDDEN,
4476 Html(
4477 template
4478 .render()
4479 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4480 ),
4481 )
4482 .into_response());
4483 };
4484 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4485 fs::canonicalize(root)
4486 .ok()
4487 .is_some_and(|r| canonical.starts_with(&r))
4488 });
4489 if !allowed {
4490 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4491 "Scan path not in allowed_scan_roots");
4492 let template = ErrorTemplate {
4493 message: "The requested path is not within an allowed scan directory.".to_string(),
4494 last_report_url: None,
4495 last_report_label: None,
4496 run_id: None,
4497 error_code: Some(403),
4498 csp_nonce: csp_nonce.to_owned(),
4499 version: env!("CARGO_PKG_VERSION"),
4500 };
4501 return Err((
4502 StatusCode::FORBIDDEN,
4503 Html(
4504 template
4505 .render()
4506 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4507 ),
4508 )
4509 .into_response());
4510 }
4511 Ok(())
4512}
4513
4514fn apply_output_dir_exclusions(
4516 config: &mut sloc_config::AppConfig,
4517 project_path: &str,
4518 raw_output_dir: &str,
4519) {
4520 let project_root = resolve_input_path(project_path);
4521 let raw_out = raw_output_dir.trim();
4522 let resolved_out = if raw_out.is_empty() {
4523 project_root.join("sloc")
4524 } else if Path::new(raw_out).is_absolute() {
4525 PathBuf::from(raw_out)
4526 } else {
4527 workspace_root().join(raw_out)
4528 };
4529 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4530 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4531 let dir = first.to_string();
4532 if !config.discovery.excluded_directories.contains(&dir) {
4533 config.discovery.excluded_directories.push(dir);
4534 }
4535 }
4536 }
4537 if !config
4538 .discovery
4539 .excluded_directories
4540 .iter()
4541 .any(|d| d == "sloc")
4542 {
4543 config
4544 .discovery
4545 .excluded_directories
4546 .push("sloc".to_string());
4547 }
4548}
4549
4550const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4552 ScanSummarySnapshot {
4553 files_analyzed: run.summary_totals.files_analyzed,
4554 files_skipped: run.summary_totals.files_skipped,
4555 total_physical_lines: run.summary_totals.total_physical_lines,
4556 code_lines: run.summary_totals.code_lines,
4557 comment_lines: run.summary_totals.comment_lines,
4558 blank_lines: run.summary_totals.blank_lines,
4559 functions: run.summary_totals.functions,
4560 classes: run.summary_totals.classes,
4561 variables: run.summary_totals.variables,
4562 imports: run.summary_totals.imports,
4563 test_count: run.summary_totals.test_count,
4564 coverage_lines_found: run.summary_totals.coverage_lines_found,
4565 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4566 coverage_functions_found: run.summary_totals.coverage_functions_found,
4567 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4568 coverage_branches_found: run.summary_totals.coverage_branches_found,
4569 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4570 }
4571}
4572
4573pub(crate) fn build_run_registry_entry(
4575 run: &AnalysisRun,
4576 run_id: &str,
4577 project_label: &str,
4578 artifacts: &RunArtifacts,
4579) -> RegistryEntry {
4580 RegistryEntry {
4581 run_id: run_id.to_owned(),
4582 timestamp_utc: run.tool.timestamp_utc,
4583 project_label: project_label.to_owned(),
4584 input_roots: run.input_roots.clone(),
4585 json_path: artifacts.json_path.clone(),
4586 html_path: artifacts.html_path.clone(),
4587 pdf_path: artifacts.pdf_path.clone(),
4588 csv_path: artifacts.csv_path.clone(),
4589 xlsx_path: artifacts.xlsx_path.clone(),
4590 summary: summary_snapshot_from_run(run),
4591 git_branch: run.git_branch.clone(),
4592 git_commit: run.git_commit_short.clone(),
4593 git_commit_long: run.git_commit_long.clone(),
4594 git_author: run.git_commit_author.clone(),
4595 git_tags: run.git_tags.clone(),
4596 git_nearest_tag: run.git_nearest_tag.clone(),
4597 git_commit_date: run.git_commit_date.clone(),
4598 }
4599}
4600
4601fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4603 if let Some(policy) = form.mixed_line_policy {
4604 config.analysis.mixed_line_policy = policy;
4605 }
4606 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4607 config.analysis.generated_file_detection =
4608 form.generated_file_detection.as_deref() != Some("disabled");
4609 config.analysis.minified_file_detection =
4610 form.minified_file_detection.as_deref() != Some("disabled");
4611 config.analysis.vendor_directory_detection =
4612 form.vendor_directory_detection.as_deref() != Some("disabled");
4613 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4614 if let Some(binary_behavior) = form.binary_file_behavior {
4615 config.analysis.binary_file_behavior = binary_behavior;
4616 }
4617 apply_report_opts(config, form);
4618 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4619 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4620 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4621 if let Some(policy) = form.continuation_line_policy {
4622 config.analysis.continuation_line_policy = policy;
4623 }
4624 if let Some(policy) = form.blank_in_block_comment_policy {
4625 config.analysis.blank_in_block_comment_policy = policy;
4626 }
4627 config.analysis.count_compiler_directives =
4628 form.count_compiler_directives.as_deref() != Some("disabled");
4629 apply_style_threshold(config, form);
4630 apply_coverage_path(config, form);
4631}
4632
4633fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4634 if let Some(report_title) = form.report_title.as_deref() {
4635 let trimmed = report_title.trim();
4636 if !trimmed.is_empty() {
4637 config.reporting.report_title = trimmed.to_string();
4638 }
4639 }
4640 if let Some(hf) = form.report_header_footer.as_deref() {
4641 let trimmed = hf.trim();
4642 config.reporting.report_header_footer = if trimmed.is_empty() {
4643 None
4644 } else {
4645 Some(trimmed.to_string())
4646 };
4647 }
4648}
4649
4650fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4651 apply_style_col_threshold(config, form);
4652 apply_style_analysis_enabled(config, form);
4653 apply_style_score_threshold(config, form);
4654 apply_style_lang_scope(config, form);
4655 apply_activity_window(config, form);
4656}
4657
4658fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4659 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4660 if let Ok(t) = threshold_str.parse::<u16>() {
4661 if t == 80 || t == 100 || t == 120 {
4662 config.analysis.style_col_threshold = t;
4663 }
4664 }
4665 }
4666}
4667
4668fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4669 if let Some(v) = form.style_analysis_enabled.as_deref() {
4670 config.analysis.style_analysis_enabled = v != "disabled";
4671 }
4672}
4673
4674fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4675 if let Some(v) = form.style_score_threshold.as_deref() {
4676 if let Ok(t) = v.parse::<u8>() {
4677 config.analysis.style_score_threshold = t.min(100);
4678 }
4679 }
4680}
4681
4682fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4683 if let Some(v) = form.style_lang_scope.as_deref() {
4684 let scope = v.trim();
4685 if scope == "c_family" || scope == "all" {
4686 config.analysis.style_lang_scope = scope.to_string();
4687 }
4688 }
4689}
4690
4691fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4692 if let Some(w) = form.activity_window.as_deref() {
4695 let w = w.trim();
4696 if !w.is_empty() {
4697 if let Ok(days) = w.parse::<u32>() {
4698 config.analysis.activity_window_days = Some(days);
4699 }
4700 }
4701 }
4702}
4703
4704fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4705 if let Some(cov) = &form.coverage_file {
4706 let trimmed = cov.trim();
4707 if !trimmed.is_empty() {
4708 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4709 }
4710 }
4711}
4712
4713fn spawn_pdf_background(
4717 pending_pdf: PendingPdf,
4718 run_id: String,
4719 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4720) {
4721 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4722 tokio::spawn(async move {
4723 let result = tokio::task::spawn_blocking(move || {
4724 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4725 if cleanup_src {
4726 let _ = fs::remove_file(&pdf_src);
4727 }
4728 r
4729 })
4730 .await;
4731 let failed = match result {
4732 Ok(Ok(())) => false,
4733 Ok(Err(err)) => {
4734 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4735 true
4736 }
4737 Err(err) => {
4738 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4739 true
4740 }
4741 };
4742 if failed {
4743 let mut map = artifacts.lock().await;
4744 if let Some(entry) = map.get_mut(&run_id) {
4745 entry.pdf_path = None;
4746 }
4747 }
4748 });
4749 }
4750}
4751
4752fn spawn_native_pdf_background(
4756 json_path: PathBuf,
4757 pdf_dest: PathBuf,
4758 run_id: String,
4759 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4760) {
4761 tokio::spawn(async move {
4762 let result = tokio::task::spawn_blocking(move || {
4763 let run = sloc_core::read_json(&json_path)?;
4764 write_pdf_from_run(&run, &pdf_dest)
4765 })
4766 .await;
4767 let failed = match result {
4768 Ok(Ok(())) => false,
4769 Ok(Err(err)) => {
4770 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4771 true
4772 }
4773 Err(err) => {
4774 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4775 true
4776 }
4777 };
4778 if failed {
4779 let mut map = artifacts.lock().await;
4780 if let Some(entry) = map.get_mut(&run_id) {
4781 entry.pdf_path = None;
4782 }
4783 }
4784 });
4785}
4786
4787fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4789 cmp.file_deltas
4790 .iter()
4791 .map(|f| match f.status {
4792 FileChangeStatus::Added => f.current_code,
4793 FileChangeStatus::Modified => f.code_delta.max(0),
4794 _ => 0,
4795 })
4796 .sum()
4797}
4798
4799fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4801 cmp.file_deltas
4802 .iter()
4803 .map(|f| match f.status {
4804 FileChangeStatus::Removed => f.baseline_code,
4805 FileChangeStatus::Modified => (-f.code_delta).max(0),
4806 _ => 0,
4807 })
4808 .sum()
4809}
4810
4811fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4813 cmp.file_deltas
4814 .iter()
4815 .filter(|f| f.status == FileChangeStatus::Unchanged)
4816 .map(|f| f.current_code)
4817 .sum()
4818}
4819
4820fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4822 cmp.file_deltas
4823 .iter()
4824 .filter(|f| f.status == FileChangeStatus::Modified)
4825 .map(|f| f.current_code)
4826 .sum()
4827}
4828
4829fn build_submodule_row(
4831 s: &sloc_core::SubmoduleSummary,
4832 run: &AnalysisRun,
4833 run_id: &str,
4834 run_dir: &Path,
4835) -> SubmoduleRow {
4836 let safe = sanitize_project_label(&s.name);
4837 let artifact_key = format!("sub_{safe}");
4838 let pdf_artifact_key = format!("sub_{safe}_pdf");
4839 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4840 let parent_path = run
4841 .input_roots
4842 .first()
4843 .map_or("", std::string::String::as_str);
4844 let sub_run = build_sub_run(run, s, parent_path);
4845 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4846 render_sub_report_html(&sub_run, Some(&pdf_server_url))
4847 .ok()
4848 .and_then(|sub_html| {
4849 let sub_dir = run_dir.join("submodules");
4850 let _ = fs::create_dir_all(&sub_dir);
4851 let html_path = sub_dir.join(format!("{artifact_key}.html"));
4852 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4853 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4856 let _ = write_pdf_from_run(&sub_run, &pdf_path);
4857 Some(format!("/runs/{artifact_key}/{run_id}"))
4858 } else {
4859 None
4860 }
4861 })
4862 } else {
4863 None
4864 };
4865 SubmoduleRow {
4866 name: s.name.clone(),
4867 relative_path: s.relative_path.clone(),
4868 files_analyzed: s.files_analyzed,
4869 code_lines: s.code_lines,
4870 comment_lines: s.comment_lines,
4871 blank_lines: s.blank_lines,
4872 total_physical_lines: s.total_physical_lines,
4873 html_url,
4874 }
4875}
4876
4877#[allow(clippy::similar_names)]
4880#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4882async fn analyze_handler(
4883 State(state): State<AppState>,
4884 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4885 Form(form): Form<AnalyzeForm>,
4886) -> impl IntoResponse {
4887 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4888 let template = ErrorTemplate {
4889 message: format!(
4890 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4891 Please wait a moment and try again."
4892 ),
4893 last_report_url: None,
4894 last_report_label: None,
4895 run_id: None,
4896 error_code: Some(503),
4897 csp_nonce: csp_nonce.clone(),
4898 version: env!("CARGO_PKG_VERSION"),
4899 };
4900 return (
4901 StatusCode::SERVICE_UNAVAILABLE,
4902 Html(
4903 template
4904 .render()
4905 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4906 ),
4907 )
4908 .into_response();
4909 };
4910
4911 let mut config = state.base_config.clone();
4912
4913 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4914 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4915 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4916
4917 if !is_git_mode {
4918 let resolved_path = resolve_input_path(&form.path);
4919 if state.server_mode
4920 && !is_upload_tmp_path(&resolved_path)
4921 && !is_sample_path(&resolved_path)
4922 {
4923 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4924 return resp;
4925 }
4926 }
4927 config.discovery.root_paths = vec![resolved_path];
4928 }
4929
4930 apply_form_to_config(&mut config, &form);
4931 apply_output_dir_exclusions(
4932 &mut config,
4933 &form.path,
4934 form.output_dir.as_deref().unwrap_or(""),
4935 );
4936
4937 let wait_id = uuid::Uuid::new_v4().to_string();
4939 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4940
4941 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4943 let task_cancel = Arc::clone(&cancel_token);
4944
4945 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4947 let task_phase = Arc::clone(&phase);
4948
4949 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4950 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4951 let task_files_done = Arc::clone(&files_done);
4952 let task_files_total = Arc::clone(&files_total);
4953
4954 {
4957 let mut runs = state.async_runs.lock().await;
4958 runs.insert(
4959 wait_id.clone(),
4960 AsyncRunState::Running {
4961 started_at: std::time::Instant::now(),
4962 cancel_token,
4963 phase,
4964 files_done,
4965 files_total,
4966 },
4967 );
4968 }
4969
4970 let task = AnalysisTask {
4971 sem_permit,
4972 state: state.clone(),
4973 wait_id: wait_id.clone(),
4974 config,
4975 cancel: task_cancel,
4976 phase: task_phase,
4977 files_done: task_files_done,
4978 files_total: task_files_total,
4979 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4980 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4981 project_path: form.path.clone(),
4982 output_dir: if state.server_mode {
4986 None
4987 } else {
4988 form.output_dir.clone()
4989 },
4990 clones_dir: state.git_clones_dir.clone(),
4991 cocomo_mode: form
4992 .cocomo_mode
4993 .clone()
4994 .unwrap_or_else(|| "organic".to_string()),
4995 complexity_alert: form
4996 .complexity_alert
4997 .as_deref()
4998 .and_then(|s| s.parse::<u32>().ok())
4999 .unwrap_or(0),
5000 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5001 };
5002
5003 tokio::spawn(run_analysis_task(task));
5004
5005 let template = ScanWaitTemplate {
5006 version: env!("CARGO_PKG_VERSION"),
5007 wait_id_json,
5008 project_path: form.path.clone(),
5009 csp_nonce,
5010 };
5011 let html = template
5012 .render()
5013 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5014 let mut response = Html(html).into_response();
5015 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5016 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5017 response.headers_mut().insert(name, val);
5018 }
5019 }
5020 response
5021}
5022
5023struct AnalysisTask {
5024 sem_permit: tokio::sync::OwnedSemaphorePermit,
5025 state: AppState,
5026 wait_id: String,
5027 config: AppConfig,
5028 cancel: Arc<std::sync::atomic::AtomicBool>,
5029 phase: Arc<std::sync::Mutex<String>>,
5030 files_done: Arc<std::sync::atomic::AtomicUsize>,
5031 files_total: Arc<std::sync::atomic::AtomicUsize>,
5032 git_repo: Option<String>,
5033 git_ref: Option<String>,
5034 project_path: String,
5035 output_dir: Option<String>,
5036 clones_dir: PathBuf,
5037 cocomo_mode: String,
5038 complexity_alert: u32,
5039 exclude_duplicates: bool,
5040}
5041
5042#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5044 let _permit = task.sem_permit;
5045
5046 let cancel_sb = Arc::clone(&task.cancel);
5047 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5048 let clones_dir_sb = task.clones_dir;
5049 let upload_staging_root = task
5051 .config
5052 .discovery
5053 .root_paths
5054 .first()
5055 .filter(|p| is_upload_tmp_path(p))
5056 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5057 .map(PathBuf::from);
5058 let config_sb = task.config;
5059 let progress_sb = sloc_core::ProgressCounters {
5060 files_done: Arc::clone(&task.files_done),
5061 files_total: Arc::clone(&task.files_total),
5062 };
5063 if let Ok(mut p) = task.phase.lock() {
5064 *p = "Scanning files".to_string();
5065 }
5066 let analysis_result = tokio::task::spawn_blocking(move || {
5067 run_analysis_blocking(
5068 config_sb,
5069 git_repo_sb,
5070 git_ref_sb,
5071 clones_dir_sb,
5072 cancel_sb,
5073 Some(progress_sb),
5074 )
5075 })
5076 .await
5077 .map_err(|err| anyhow::anyhow!(err.to_string()))
5078 .and_then(|result| result);
5079
5080 if let Ok(mut p) = task.phase.lock() {
5081 *p = "Writing reports".to_string();
5082 }
5083
5084 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5086 let mut runs = task.state.async_runs.lock().await;
5087 if matches!(
5089 runs.get(&task.wait_id),
5090 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5091 ) {
5092 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5093 }
5094 drop(runs);
5095 return;
5096 }
5097
5098 let run = match analysis_result {
5099 Ok(v) => v,
5100 Err(err) => {
5101 if err.to_string().contains("analysis cancelled") {
5103 let mut runs = task.state.async_runs.lock().await;
5104 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5105 drop(runs);
5106 return;
5107 }
5108 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5109 let mut runs = task.state.async_runs.lock().await;
5110 runs.insert(
5111 task.wait_id.clone(),
5112 AsyncRunState::Failed {
5113 message: "Analysis failed. Check that the path exists and is readable."
5114 .to_string(),
5115 },
5116 );
5117 drop(runs);
5118 return;
5119 }
5120 };
5121
5122 let run_id = run.tool.run_id.clone();
5123 tracing::info!(event = "scan_complete", run_id = %run_id,
5124 path = %task.project_path, files = run.summary_totals.files_analyzed,
5125 "Analysis finished");
5126
5127 let prev_entry: Option<RegistryEntry> = {
5128 let reg = task.state.registry.lock().await;
5129 reg.entries_for_roots(&run.input_roots)
5130 .into_iter()
5131 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5132 .cloned()
5133 };
5134
5135 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5136 prev.json_path
5137 .as_ref()
5138 .and_then(|p| read_json(p).ok())
5139 .map(|prev_run| compute_delta(&prev_run, &run))
5140 });
5141 let prev_scan_count: usize = {
5142 let reg = task.state.registry.lock().await;
5143 reg.entries_for_roots(&run.input_roots)
5144 .iter()
5145 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5146 .count()
5147 };
5148
5149 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5152 .as_ref()
5153 .zip(prev_entry.as_ref())
5154 .map(|(cmp, prev)| ReportDeltaContext {
5155 delta_code_added: sum_added_code_lines(cmp),
5156 delta_code_removed: sum_removed_code_lines(cmp),
5157 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5158 delta_files_added: cmp.files_added,
5159 delta_files_removed: cmp.files_removed,
5160 delta_files_modified: cmp.files_modified,
5161 delta_files_unchanged: cmp.files_unchanged,
5162 prev_code_lines: prev.summary.code_lines,
5163 prev_scan_count: prev_scan_count + 1,
5164 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5165 prev_run_id: Some(prev.run_id.clone()),
5166 current_run_id: Some(run_id.clone()),
5167 });
5168 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5169 Ok(h) => h,
5170 Err(err) => {
5171 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5172 let mut runs = task.state.async_runs.lock().await;
5173 runs.insert(
5174 task.wait_id.clone(),
5175 AsyncRunState::Failed {
5176 message: "Failed to render HTML report.".to_string(),
5177 },
5178 );
5179 drop(runs);
5180 return;
5181 }
5182 };
5183
5184 let output_root = resolve_output_root(task.output_dir.as_deref());
5185 let project_label = derive_project_label(
5186 task.git_repo.as_deref(),
5187 task.git_ref.as_deref(),
5188 &task.project_path,
5189 );
5190 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5191 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5192
5193 let result_context = RunResultContext {
5194 prev_entry: prev_entry.clone(),
5195 prev_scan_count,
5196 project_path: task.project_path.clone(),
5197 cocomo_mode: task.cocomo_mode.clone(),
5198 complexity_alert: task.complexity_alert,
5199 exclude_duplicates: task.exclude_duplicates,
5200 };
5201
5202 let artifact_result = persist_run_artifacts(
5203 &run,
5204 &report_html,
5205 &run_dir,
5206 &run.effective_configuration.reporting.report_title,
5207 &file_stem,
5208 result_context,
5209 );
5210
5211 let (artifacts, pending_pdf) = match artifact_result {
5212 Ok(v) => v,
5213 Err(err) => {
5214 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5215 let mut runs = task.state.async_runs.lock().await;
5216 runs.insert(
5217 task.wait_id.clone(),
5218 AsyncRunState::Failed {
5219 message: "Failed to save report artifacts. Check available disk space."
5220 .to_string(),
5221 },
5222 );
5223 drop(runs);
5224 return;
5225 }
5226 };
5227
5228 {
5229 let mut map = task.state.artifacts.lock().await;
5230 map.insert(run_id.clone(), artifacts.clone());
5231 }
5232
5233 {
5234 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5235 let mut reg = task.state.registry.lock().await;
5236 reg.add_entry(entry);
5237 let _ = reg.save(&task.state.registry_path);
5238 }
5239
5240 if let Some(ref cfg_path) = artifacts.scan_config_path {
5241 save_scan_config_json(
5242 cfg_path,
5243 &run,
5244 &task.project_path,
5245 task.output_dir.as_deref(),
5246 &task.cocomo_mode,
5247 task.complexity_alert,
5248 task.exclude_duplicates,
5249 );
5250 }
5251
5252 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5253
5254 prom_runs_total().inc();
5255
5256 let mut runs = task.state.async_runs.lock().await;
5258 runs.insert(
5259 task.wait_id.clone(),
5260 AsyncRunState::Complete {
5261 run_id: run_id.clone(),
5262 },
5263 );
5264 drop(runs);
5265
5266 if let Some(staging) = upload_staging_root {
5269 let _ = tokio::fs::remove_dir_all(staging).await;
5270 }
5271
5272 let _ = scan_delta;
5273}
5274
5275fn save_scan_config_json(
5276 cfg_path: &std::path::Path,
5277 run: &sloc_core::AnalysisRun,
5278 project_path: &str,
5279 output_dir: Option<&str>,
5280 cocomo_mode: &str,
5281 complexity_alert: u32,
5282 exclude_duplicates: bool,
5283) {
5284 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5285 .ok()
5286 .and_then(|v| v.as_str().map(String::from))
5287 .unwrap_or_else(|| "code_only".to_string());
5288 let behavior_str =
5289 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5290 .ok()
5291 .and_then(|v| v.as_str().map(String::from))
5292 .unwrap_or_else(|| "skip".to_string());
5293 let continuation_policy_str = serde_json::to_value(
5294 run.effective_configuration
5295 .analysis
5296 .continuation_line_policy,
5297 )
5298 .ok()
5299 .and_then(|v| v.as_str().map(String::from))
5300 .unwrap_or_else(default_each_physical_line);
5301 let blank_policy_str = serde_json::to_value(
5302 run.effective_configuration
5303 .analysis
5304 .blank_in_block_comment_policy,
5305 )
5306 .ok()
5307 .and_then(|v| v.as_str().map(String::from))
5308 .unwrap_or_else(default_count_as_comment);
5309 let scan_cfg = ScanConfig {
5310 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5311 path: project_path.to_string(),
5312 include_globs: run
5313 .effective_configuration
5314 .discovery
5315 .include_globs
5316 .join("\n"),
5317 exclude_globs: run
5318 .effective_configuration
5319 .discovery
5320 .exclude_globs
5321 .join("\n"),
5322 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5323 mixed_line_policy: policy_str,
5324 python_docstrings_as_comments: run
5325 .effective_configuration
5326 .analysis
5327 .python_docstrings_as_comments,
5328 generated_file_detection: run
5329 .effective_configuration
5330 .analysis
5331 .generated_file_detection,
5332 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5333 vendor_directory_detection: run
5334 .effective_configuration
5335 .analysis
5336 .vendor_directory_detection,
5337 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5338 binary_file_behavior: behavior_str,
5339 output_dir: output_dir.unwrap_or("").to_string(),
5340 report_title: run.effective_configuration.reporting.report_title.clone(),
5341 continuation_line_policy: continuation_policy_str,
5342 blank_in_block_comment_policy: blank_policy_str,
5343 count_compiler_directives: run
5344 .effective_configuration
5345 .analysis
5346 .count_compiler_directives,
5347 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5348 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5349 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5350 style_lang_scope: run
5351 .effective_configuration
5352 .analysis
5353 .style_lang_scope
5354 .clone(),
5355 coverage_file: run
5356 .effective_configuration
5357 .analysis
5358 .coverage_file
5359 .as_ref()
5360 .map(|p| p.display().to_string())
5361 .unwrap_or_default(),
5362 cocomo_mode: cocomo_mode.to_string(),
5363 complexity_alert,
5364 exclude_duplicates,
5365 activity_window: run
5366 .effective_configuration
5367 .analysis
5368 .activity_window_days
5369 .unwrap_or(0),
5370 };
5371 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5372 let _ = std::fs::write(cfg_path, json);
5373 }
5374}
5375
5376#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5378 mut config: AppConfig,
5379 git_repo: Option<String>,
5380 git_ref: Option<String>,
5381 clones_dir: PathBuf,
5382 cancel: Arc<std::sync::atomic::AtomicBool>,
5383 progress: Option<sloc_core::ProgressCounters>,
5384) -> Result<sloc_core::AnalysisRun> {
5385 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5386 let dest = git_clone_dest(&repo, &clones_dir);
5387 sloc_git::clone_or_fetch(&repo, &dest)?;
5388 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5389 sloc_git::create_worktree(&dest, &refname, &wt)?;
5390 config.discovery.root_paths = vec![wt.clone()];
5391 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5392 let _ = sloc_git::destroy_worktree(&dest, &wt);
5393 let mut run = run?;
5394 if run.git_branch.is_none() {
5395 run.git_branch = Some(refname);
5396 }
5397 return Ok(run);
5398 }
5399 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5400}
5401
5402fn derive_project_label(
5403 git_repo: Option<&str>,
5404 git_ref: Option<&str>,
5405 fallback_path: &str,
5406) -> String {
5407 match (
5408 git_repo.filter(|s| !s.is_empty()),
5409 git_ref.filter(|s| !s.is_empty()),
5410 ) {
5411 (Some(repo), Some(refname)) => {
5412 let repo_name = repo
5413 .trim_end_matches('/')
5414 .trim_end_matches(".git")
5415 .rsplit('/')
5416 .next()
5417 .unwrap_or("repo");
5418 sanitize_project_label(&format!("{repo_name}_{refname}"))
5419 }
5420 _ => sanitize_project_label(fallback_path),
5421 }
5422}
5423
5424fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5425 let commit = commit_short.unwrap_or("").trim();
5426 if commit.is_empty() {
5427 project_label.to_string()
5428 } else {
5429 format!("{project_label}_{commit}")
5430 }
5431}
5432
5433#[derive(Serialize)]
5436#[serde(tag = "state", rename_all = "snake_case")]
5437enum AsyncRunStatusResponse {
5438 Running {
5439 elapsed_secs: u64,
5440 phase: String,
5441 files_done: u64,
5442 files_total: u64,
5443 },
5444 Complete {
5445 run_id: String,
5446 },
5447 Failed {
5448 message: String,
5449 },
5450 Cancelled,
5451}
5452
5453async fn async_run_status_handler(
5454 State(state): State<AppState>,
5455 AxumPath(wait_id): AxumPath<String>,
5456) -> Response {
5457 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5459 return error::bad_request("invalid wait_id");
5460 }
5461 let run_state = {
5462 let runs = state.async_runs.lock().await;
5463 runs.get(&wait_id).cloned()
5464 };
5465 match run_state {
5466 None => error::not_found("run not found"),
5467 Some(AsyncRunState::Running {
5468 started_at,
5469 phase,
5470 files_done,
5471 files_total,
5472 ..
5473 }) => {
5474 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5476 let mut runs = state.async_runs.lock().await;
5477 runs.insert(
5478 wait_id,
5479 AsyncRunState::Failed {
5480 message: "Analysis timed out after 2 hours.".to_string(),
5481 },
5482 );
5483 drop(runs);
5484 return Json(AsyncRunStatusResponse::Failed {
5485 message: "Analysis timed out after 2 hours.".to_string(),
5486 })
5487 .into_response();
5488 }
5489 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5490 Json(AsyncRunStatusResponse::Running {
5491 elapsed_secs: started_at.elapsed().as_secs(),
5492 phase: phase_str,
5493 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5494 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5495 })
5496 .into_response()
5497 }
5498 Some(AsyncRunState::Complete { run_id }) => {
5499 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5500 }
5501 Some(AsyncRunState::Failed { message }) => {
5502 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5503 }
5504 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5505 }
5506}
5507
5508async fn cancel_run_handler(
5509 State(state): State<AppState>,
5510 AxumPath(wait_id): AxumPath<String>,
5511) -> Response {
5512 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5513 return error::bad_request("invalid wait_id");
5514 }
5515 let mut runs = state.async_runs.lock().await;
5516 let resp = match runs.get(&wait_id) {
5517 Some(AsyncRunState::Running { cancel_token, .. }) => {
5518 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5519 runs.insert(wait_id, AsyncRunState::Cancelled);
5520 StatusCode::OK.into_response()
5521 }
5522 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5523 _ => error::not_found("run not found"),
5524 };
5525 drop(runs);
5526 resp
5527}
5528
5529async fn async_run_result_handler(
5530 State(state): State<AppState>,
5531 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5532 AxumPath(run_id): AxumPath<String>,
5533) -> Response {
5534 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5535 return StatusCode::BAD_REQUEST.into_response();
5536 }
5537
5538 let artifacts = {
5539 let map = state.artifacts.lock().await;
5540 map.get(&run_id).cloned()
5541 };
5542 let artifacts = if let Some(a) = artifacts {
5543 a
5544 } else {
5545 let reg = state.registry.lock().await;
5546 if let Some(entry) = reg.find_by_run_id(&run_id) {
5547 recover_artifacts_from_registry(entry)
5548 } else {
5549 let html = ErrorTemplate {
5550 message: format!(
5551 "Report not found. Run ID {} is not in the scan history.",
5552 &run_id[..run_id.len().min(8)]
5553 ),
5554 last_report_url: Some("/view-reports".to_string()),
5555 last_report_label: Some("View Reports".to_string()),
5556 run_id: Some(run_id.clone()),
5557 error_code: Some(404),
5558 csp_nonce: csp_nonce.clone(),
5559 version: env!("CARGO_PKG_VERSION"),
5560 }
5561 .render()
5562 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5563 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5564 }
5565 };
5566
5567 let json_path = if let Some(p) = &artifacts.json_path {
5568 p.clone()
5569 } else {
5570 let html = ErrorTemplate {
5571 message: "JSON result was not saved for this run.".to_string(),
5572 last_report_url: Some("/view-reports".to_string()),
5573 last_report_label: Some("View Reports".to_string()),
5574 run_id: Some(run_id.clone()),
5575 error_code: Some(404),
5576 csp_nonce: csp_nonce.clone(),
5577 version: env!("CARGO_PKG_VERSION"),
5578 }
5579 .render()
5580 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5581 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5582 };
5583
5584 let Ok(run) = read_json(&json_path) else {
5585 let folder_hint = output_folder_hint(&json_path);
5586 let redirect_url = format!("/runs/result/{run_id}");
5587 return missing_scan_relocate_response(
5588 &format!(
5589 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5590 deleted. Browse to the folder containing your scan output to reconnect it.",
5591 json_path.display()
5592 ),
5593 &run_id,
5594 &folder_hint,
5595 &redirect_url,
5596 state.server_mode,
5597 &csp_nonce,
5598 );
5599 };
5600
5601 let confluence_configured = {
5602 let store = state.confluence.lock().await;
5603 store.is_configured()
5604 };
5605
5606 render_result_page(
5607 &run,
5608 &artifacts,
5609 &run_id,
5610 &csp_nonce,
5611 confluence_configured,
5612 state.server_mode,
5613 )
5614}
5615
5616fn json_escape(s: &str) -> String {
5618 s.replace('\\', "\\\\").replace('"', "\\\"")
5619}
5620
5621struct LangTotals {
5623 physical_lines: u64,
5624 code_lines: u64,
5625 comment_lines: u64,
5626 blank_lines: u64,
5627 mixed_lines: u64,
5628 functions: u64,
5629 classes: u64,
5630 variables: u64,
5631 imports: u64,
5632}
5633
5634fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5635 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5636 run.totals_by_language.iter().map(f).sum()
5637 };
5638 LangTotals {
5639 physical_lines: s(|r| r.total_physical_lines),
5640 code_lines: s(|r| r.code_lines),
5641 comment_lines: s(|r| r.comment_lines),
5642 blank_lines: s(|r| r.blank_lines),
5643 mixed_lines: s(|r| r.mixed_lines_separate),
5644 functions: s(|r| r.functions),
5645 classes: s(|r| r.classes),
5646 variables: s(|r| r.variables),
5647 imports: s(|r| r.imports),
5648 }
5649}
5650
5651struct DeltaFields {
5653 prev_fa_str: String,
5654 prev_fs_str: String,
5655 prev_pl_str: String,
5656 prev_cl_str: String,
5657 prev_cml_str: String,
5658 prev_bl_str: String,
5659 delta_fa_str: String,
5660 delta_fa_class: String,
5661 delta_fs_str: String,
5662 delta_fs_class: String,
5663 delta_pl_str: String,
5664 delta_pl_class: String,
5665 delta_cl_str: String,
5666 delta_cl_class: String,
5667 delta_cml_str: String,
5668 delta_cml_class: String,
5669 delta_bl_str: String,
5670 delta_bl_class: String,
5671 delta_lines_added: Option<i64>,
5672 delta_lines_removed: Option<i64>,
5673 delta_lines_net_str: String,
5674 delta_lines_net_class: String,
5675}
5676
5677#[allow(
5682 clippy::similar_names,
5683 reason = "locals mirror template-bound struct fields"
5684)]
5685fn compute_delta_fields(
5686 prev_entry: Option<&RegistryEntry>,
5687 totals: &LangTotals,
5688 files_analyzed: u64,
5689 files_skipped: u64,
5690 scan_delta: Option<&sloc_core::ScanComparison>,
5691) -> DeltaFields {
5692 let prev_sum = prev_entry.map(|e| &e.summary);
5693 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5694
5695 let (delta_fa_str, delta_fa_class) =
5696 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5697 let (delta_fs_str, delta_fs_class) =
5698 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5699 let (delta_pl_str, delta_pl_class) = summary_delta(
5700 totals.physical_lines,
5701 prev_sum.map(|s| s.total_physical_lines),
5702 );
5703 let (delta_cl_str, delta_cl_class) =
5704 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5705 let (delta_cml_str, delta_cml_class) =
5706 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5707 let (delta_bl_str, delta_bl_class) =
5708 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5709
5710 let delta_lines_added = scan_delta.map(sum_added_code_lines);
5711 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5712 let (delta_lines_net_str, delta_lines_net_class) =
5713 match (delta_lines_added, delta_lines_removed) {
5714 (Some(a), Some(r)) => {
5715 let net = a - r;
5716 (fmt_delta(net), delta_class(net).to_string())
5717 }
5718 _ => ("\u{2014}".to_string(), "na".to_string()),
5719 };
5720
5721 DeltaFields {
5722 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5723 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5724 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5725 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5726 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5727 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5728 delta_fa_str,
5729 delta_fa_class: delta_fa_class.to_string(),
5730 delta_fs_str,
5731 delta_fs_class: delta_fs_class.to_string(),
5732 delta_pl_str,
5733 delta_pl_class: delta_pl_class.to_string(),
5734 delta_cl_str,
5735 delta_cl_class: delta_cl_class.to_string(),
5736 delta_cml_str,
5737 delta_cml_class: delta_cml_class.to_string(),
5738 delta_bl_str,
5739 delta_bl_class: delta_bl_class.to_string(),
5740 delta_lines_added,
5741 delta_lines_removed,
5742 delta_lines_net_str,
5743 delta_lines_net_class,
5744 }
5745}
5746
5747fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5749 scan_delta
5750 .file_deltas
5751 .iter()
5752 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5753 .map(|f| {
5754 #[allow(clippy::cast_sign_loss)]
5755 let n = f.current_code as u64;
5756 n
5757 })
5758 .sum()
5759}
5760
5761fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5762 run.git_remote_url
5763 .as_deref()
5764 .zip(run.git_commit_long.as_deref())
5765 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5766}
5767
5768fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5769 run.git_remote_url
5770 .as_deref()
5771 .zip(run.git_branch.as_deref())
5772 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5773}
5774
5775fn scan_performed_by(run: &AnalysisRun) -> String {
5776 run.environment.ci_name.clone().unwrap_or_else(|| {
5777 format!(
5778 "{} / {}",
5779 run.environment.initiator_username, run.environment.initiator_hostname
5780 )
5781 })
5782}
5783
5784fn build_lang_chart_json(run: &AnalysisRun) -> String {
5786 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5787 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5788 let entries: Vec<String> = langs
5789 .into_iter()
5790 .take(12)
5791 .map(|l| {
5792 let name = json_escape(l.language.display_name());
5793 format!(
5794 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5795 name,
5796 l.code_lines,
5797 l.comment_lines,
5798 l.blank_lines,
5799 l.total_physical_lines,
5800 l.functions,
5801 l.classes,
5802 l.variables,
5803 l.imports,
5804 l.files,
5805 )
5806 })
5807 .collect();
5808 format!("[{}]", entries.join(","))
5809}
5810
5811fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5813 let entries: Vec<String> = run
5814 .totals_by_language
5815 .iter()
5816 .map(|l| {
5817 let name = json_escape(l.language.display_name());
5818 format!(
5819 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5820 name, l.files, l.code_lines, l.total_physical_lines,
5821 )
5822 })
5823 .collect();
5824 format!("[{}]", entries.join(","))
5825}
5826
5827fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5829 let entries: Vec<String> = run
5830 .totals_by_language
5831 .iter()
5832 .filter(|l| {
5833 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5834 })
5835 .map(|l| {
5836 let name = json_escape(l.language.display_name());
5837 format!(
5838 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5839 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5840 )
5841 })
5842 .collect();
5843 format!("[{}]", entries.join(","))
5844}
5845
5846fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5848 let entries: Vec<String> = run
5849 .submodule_summaries
5850 .iter()
5851 .map(|s| {
5852 let name = json_escape(&s.name);
5853 format!(
5854 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5855 name,
5856 s.code_lines,
5857 s.comment_lines,
5858 s.blank_lines,
5859 s.total_physical_lines,
5860 s.files_analyzed,
5861 )
5862 })
5863 .collect();
5864 format!("[{}]", entries.join(","))
5865}
5866
5867#[allow(clippy::cast_precision_loss)]
5869fn cov_pct_str(hit: u64, found: u64) -> String {
5870 if found > 0 {
5871 format!("{:.1}", hit as f64 / found as f64 * 100.0)
5872 } else {
5873 String::new()
5874 }
5875}
5876
5877fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5879 if found > 0 {
5880 format!("{hit} / {found}")
5881 } else {
5882 String::new()
5883 }
5884}
5885
5886const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5887 use sloc_core::CocomoMode;
5888 match mode {
5889 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5890 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5891 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5892 }
5893}
5894
5895const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5896 use sloc_core::CocomoMode;
5897 match mode {
5898 CocomoMode::Organic => "Organic",
5899 CocomoMode::SemiDetached => "Semi-detached",
5900 CocomoMode::Embedded => "Embedded",
5901 }
5902}
5903
5904const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5905 use sloc_core::CocomoMode;
5906 match mode {
5907 CocomoMode::Organic => {
5908 "Organic: A small team working on a well-understood project in a familiar \
5909 environment with minimal external constraints. Suited for internal tools, \
5910 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5911 }
5912 CocomoMode::SemiDetached => {
5913 "Semi-detached: A mixed team with varying experience tackling a project with \
5914 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5915 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5916 }
5917 CocomoMode::Embedded => {
5918 "Embedded: Tight hardware, software, or operational constraints requiring \
5919 significant innovation and deep integration work. Typical for real-time control \
5920 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5921 }
5922 }
5923}
5924
5925struct CocomoFields {
5927 has_cocomo: bool,
5928 effort_str: String,
5929 duration_str: String,
5930 staff_str: String,
5931 ksloc_str: String,
5932 mode_label: String,
5933 mode_tooltip: String,
5934}
5935
5936#[allow(clippy::cast_precision_loss)]
5937fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5938 use sloc_core::CocomoMode;
5939 let mode = match mode_str {
5940 "semi_detached" => CocomoMode::SemiDetached,
5941 "embedded" => CocomoMode::Embedded,
5942 _ => CocomoMode::Organic,
5943 };
5944 let (a, b, c, d) = cocomo_coefficients(mode);
5945 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5946 let effort = a * ksloc.powf(b);
5947 let duration = c * effort.powf(d);
5948 let staff = if duration > 0.0 {
5949 effort / duration
5950 } else {
5951 0.0
5952 };
5953 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5954 let mode_label = cocomo_mode_label(mode).to_string();
5955 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5956 if run.summary_totals.code_lines > 0 {
5957 CocomoFields {
5958 has_cocomo: true,
5959 effort_str: round2(effort),
5960 duration_str: round2(duration),
5961 staff_str: round2(staff),
5962 ksloc_str: round2(ksloc),
5963 mode_label,
5964 mode_tooltip,
5965 }
5966 } else {
5967 CocomoFields {
5968 has_cocomo: false,
5969 effort_str: String::new(),
5970 duration_str: String::new(),
5971 staff_str: String::new(),
5972 ksloc_str: String::new(),
5973 mode_label,
5974 mode_tooltip,
5975 }
5976 }
5977}
5978
5979#[allow(clippy::too_many_lines)]
5980#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
5983 run: &AnalysisRun,
5984 artifacts: &RunArtifacts,
5985 run_id: &str,
5986 csp_nonce: &str,
5987 confluence_configured: bool,
5988 server_mode: bool,
5989) -> Response {
5990 let ctx = &artifacts.result_context;
5991 let prev_entry = &ctx.prev_entry;
5992 let prev_scan_count = ctx.prev_scan_count;
5993 let project_path_owned = if ctx.project_path.is_empty() {
5997 run.input_roots.join(", ")
5998 } else {
5999 ctx.project_path.clone()
6000 };
6001 let project_path = &project_path_owned;
6002
6003 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6004 prev.json_path
6005 .as_ref()
6006 .and_then(|p| read_json(p).ok())
6007 .map(|prev_run| compute_delta(&prev_run, run))
6008 });
6009
6010 let files_analyzed = run.per_file_records.len() as u64;
6011 let files_skipped = run.skipped_file_records.len() as u64;
6012 let totals = sum_lang_totals(run);
6013
6014 let DeltaFields {
6015 prev_fa_str,
6016 prev_fs_str,
6017 prev_pl_str,
6018 prev_cl_str,
6019 prev_cml_str,
6020 prev_bl_str,
6021 delta_fa_str,
6022 delta_fa_class,
6023 delta_fs_str,
6024 delta_fs_class,
6025 delta_pl_str,
6026 delta_pl_class,
6027 delta_cl_str,
6028 delta_cl_class,
6029 delta_cml_str,
6030 delta_cml_class,
6031 delta_bl_str,
6032 delta_bl_class,
6033 delta_lines_added,
6034 delta_lines_removed,
6035 delta_lines_net_str,
6036 delta_lines_net_class,
6037 } = compute_delta_fields(
6038 prev_entry.as_ref(),
6039 &totals,
6040 files_analyzed,
6041 files_skipped,
6042 scan_delta.as_ref(),
6043 );
6044
6045 let run_dir = artifacts.output_dir.clone();
6046 let git_branch = run.git_branch.clone();
6047 let git_commit = run.git_commit_short.clone();
6048 let git_commit_long = run.git_commit_long.clone();
6049 let git_author = run.git_commit_author.clone();
6050 let git_commit_url = git_commit_url_for(run);
6051 let git_branch_url = git_branch_url_for(run);
6052 let scan_performed_by = scan_performed_by(run);
6053 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6054 let os_display = format!(
6055 "{} / {}",
6056 run.environment.operating_system, run.environment.architecture
6057 );
6058 let test_count = run.summary_totals.test_count;
6059
6060 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6062 let lsloc = run.summary_totals.lsloc;
6063 let uloc = run.uloc;
6064 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6065 let duplicate_group_count = run.duplicate_groups.len();
6066
6067 let ctx = &artifacts.result_context;
6069 let CocomoFields {
6070 has_cocomo,
6071 effort_str: cocomo_effort_str,
6072 duration_str: cocomo_duration_str,
6073 staff_str: cocomo_staff_str,
6074 ksloc_str: cocomo_ksloc_str,
6075 mode_label: cocomo_mode_label,
6076 mode_tooltip: cocomo_mode_tooltip,
6077 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6078 let complexity_alert = ctx.complexity_alert;
6079
6080 let template = ResultTemplate {
6081 version: env!("CARGO_PKG_VERSION"),
6082 report_title: run.effective_configuration.reporting.report_title.clone(),
6083 project_path: project_path.clone(),
6084 output_dir: display_path(&artifacts.output_dir),
6085 run_id: run_id.to_owned(),
6086 run_id_short: run_id
6087 .split('-')
6088 .next_back()
6089 .unwrap_or(run_id)
6090 .chars()
6091 .take(7)
6092 .collect(),
6093 files_analyzed,
6094 files_skipped,
6095 physical_lines: totals.physical_lines,
6096 code_lines: totals.code_lines,
6097 comment_lines: totals.comment_lines,
6098 blank_lines: totals.blank_lines,
6099 mixed_lines: totals.mixed_lines,
6100 functions: totals.functions,
6101 classes: totals.classes,
6102 variables: totals.variables,
6103 imports: totals.imports,
6104 html_url: artifacts
6105 .html_path
6106 .as_ref()
6107 .map(|_| format!("/runs/html/{run_id}")),
6108 pdf_url: artifacts
6109 .pdf_path
6110 .as_ref()
6111 .map(|_| format!("/runs/pdf/{run_id}")),
6112 json_url: artifacts
6113 .json_path
6114 .as_ref()
6115 .map(|_| format!("/runs/json/{run_id}")),
6116 html_download_url: artifacts
6117 .html_path
6118 .as_ref()
6119 .map(|_| format!("/runs/html/{run_id}?download=1")),
6120 pdf_download_url: artifacts
6121 .pdf_path
6122 .as_ref()
6123 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6124 json_download_url: artifacts
6125 .json_path
6126 .as_ref()
6127 .map(|_| format!("/runs/json/{run_id}?download=1")),
6128 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6129 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6130 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6131 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6132 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6133 prev_fa_str,
6134 prev_fs_str,
6135 prev_pl_str,
6136 prev_cl_str,
6137 prev_cml_str,
6138 prev_bl_str,
6139 delta_fa_str,
6140 delta_fa_class,
6141 delta_fs_str,
6142 delta_fs_class,
6143 delta_pl_str,
6144 delta_pl_class,
6145 delta_cl_str,
6146 delta_cl_class,
6147 delta_cml_str,
6148 delta_cml_class,
6149 delta_bl_str,
6150 delta_bl_class,
6151 delta_lines_added,
6152 delta_lines_removed,
6153 delta_lines_net_str,
6154 delta_lines_net_class,
6155 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6156 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6157 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6158 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6159 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6160 git_branch,
6161 git_branch_url,
6162 git_commit,
6163 git_commit_long,
6164 git_author,
6165 git_commit_url,
6166 scan_performed_by,
6167 scan_time_display,
6168 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6169 os_display,
6170 test_count,
6171 test_assertion_count: run.summary_totals.test_assertion_count,
6172 current_scan_number: prev_scan_count + 1,
6173 prev_scan_count,
6174 submodule_rows: run
6175 .submodule_summaries
6176 .iter()
6177 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6178 .collect(),
6179 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6180 scan_config_url: format!("/runs/scan-config/{run_id}"),
6181 lang_chart_json: build_lang_chart_json(run),
6182 scatter_chart_json: build_scatter_chart_json(run),
6183 semantic_chart_json: build_semantic_chart_json(run),
6184 submodule_chart_json: build_submodule_chart_json(run),
6185 has_submodule_data: !run.submodule_summaries.is_empty(),
6186 has_semantic_data: run
6187 .totals_by_language
6188 .iter()
6189 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6190 csp_nonce: csp_nonce.to_owned(),
6191 confluence_configured,
6192 server_mode,
6193 report_header_footer: run
6194 .effective_configuration
6195 .reporting
6196 .report_header_footer
6197 .clone(),
6198 is_offline: false,
6199 cyclomatic_complexity,
6200 lsloc,
6201 uloc,
6202 dryness_pct_str,
6203 duplicate_group_count,
6204 has_cocomo,
6205 cocomo_effort_str,
6206 cocomo_duration_str,
6207 cocomo_staff_str,
6208 cocomo_ksloc_str,
6209 cocomo_mode_label,
6210 cocomo_mode_tooltip,
6211 complexity_alert,
6212 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6213 cov_line_pct: cov_pct_str(
6214 run.summary_totals.coverage_lines_hit,
6215 run.summary_totals.coverage_lines_found,
6216 ),
6217 cov_fn_pct: cov_pct_str(
6218 run.summary_totals.coverage_functions_hit,
6219 run.summary_totals.coverage_functions_found,
6220 ),
6221 cov_branch_pct: cov_pct_str(
6222 run.summary_totals.coverage_branches_hit,
6223 run.summary_totals.coverage_branches_found,
6224 ),
6225 cov_lines_summary: cov_lines_summary_str(
6226 run.summary_totals.coverage_lines_hit,
6227 run.summary_totals.coverage_lines_found,
6228 ),
6229 };
6230
6231 Html(
6232 template
6233 .render()
6234 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6235 )
6236 .into_response()
6237}
6238
6239fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6240 let slug: String = report_title
6241 .chars()
6242 .map(|c| {
6243 if c.is_alphanumeric() || c == '-' {
6244 c.to_ascii_lowercase()
6245 } else {
6246 '_'
6247 }
6248 })
6249 .collect::<String>()
6250 .split('_')
6251 .filter(|s| !s.is_empty())
6252 .collect::<Vec<_>>()
6253 .join("_");
6254
6255 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6256
6257 if slug.is_empty() {
6258 format!("report_{short_id}.pdf")
6259 } else {
6260 format!("{slug}_{short_id}.pdf")
6261 }
6262}
6263
6264#[derive(Serialize)]
6265struct PdfStatusResponse {
6266 ready: bool,
6267}
6268
6269async fn pdf_status_handler(
6272 State(state): State<AppState>,
6273 AxumPath(run_id): AxumPath<String>,
6274) -> Response {
6275 let pdf_path = {
6276 let registry = state.artifacts.lock().await;
6277 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6278 };
6279 let pdf_path = if pdf_path.is_some() {
6280 pdf_path
6281 } else {
6282 let reg = state.registry.lock().await;
6283 reg.find_by_run_id(&run_id)
6284 .map(recover_artifacts_from_registry)
6285 .and_then(|a| a.pdf_path)
6286 };
6287 let ready = pdf_path.is_some_and(|p| p.exists());
6288 Json(PdfStatusResponse { ready }).into_response()
6289}
6290
6291async fn download_bundle_handler(
6297 State(state): State<AppState>,
6298 AxumPath(run_id): AxumPath<String>,
6299) -> Response {
6300 let output_dir = {
6302 let cache = state.artifacts.lock().await;
6303 cache.get(&run_id).map(|a| a.output_dir.clone())
6304 };
6305 let output_dir = if let Some(d) = output_dir {
6306 d
6307 } else {
6308 let reg = state.registry.lock().await;
6309 match reg.find_by_run_id(&run_id) {
6310 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6311 None => {
6312 return (
6313 StatusCode::NOT_FOUND,
6314 Json(serde_json::json!({"error": "Run not found"})),
6315 )
6316 .into_response();
6317 }
6318 }
6319 };
6320
6321 if !output_dir.exists() {
6322 return (
6323 StatusCode::NOT_FOUND,
6324 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6325 )
6326 .into_response();
6327 }
6328
6329 let run_id_clone = run_id.clone();
6331 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6332 use flate2::{write::GzEncoder, Compression};
6333 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6334 {
6335 let mut tar = tar::Builder::new(&mut enc);
6336 tar.follow_symlinks(false);
6337 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6340 for entry in entries.filter_map(Result::ok) {
6341 let p = entry.path();
6342 if p.is_file() {
6343 let name = p.file_name().unwrap_or_default().to_string_lossy();
6344 let archive_path = format!("{run_id_clone}/{name}");
6345 tar.append_path_with_name(&p, &archive_path)?;
6346 }
6347 }
6348 }
6349 tar.finish()?;
6350 }
6351 Ok(enc.finish()?)
6352 })
6353 .await;
6354
6355 match archive_result {
6356 Ok(Ok(bytes)) => {
6357 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6358 axum::response::Response::builder()
6359 .status(StatusCode::OK)
6360 .header("Content-Type", "application/gzip")
6361 .header(
6362 "Content-Disposition",
6363 format!("attachment; filename=\"{filename}\""),
6364 )
6365 .header("Content-Length", bytes.len().to_string())
6366 .body(axum::body::Body::from(bytes))
6367 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6368 }
6369 Ok(Err(e)) => (
6370 StatusCode::INTERNAL_SERVER_ERROR,
6371 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6372 )
6373 .into_response(),
6374 Err(e) => (
6375 StatusCode::INTERNAL_SERVER_ERROR,
6376 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6377 )
6378 .into_response(),
6379 }
6380}
6381
6382async fn delete_run_handler(
6387 State(state): State<AppState>,
6388 AxumPath(run_id): AxumPath<String>,
6389) -> Response {
6390 let output_dir = {
6392 let mut cache = state.artifacts.lock().await;
6393 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6394 cache.remove(&run_id);
6395 dir
6396 };
6397 let output_dir = if let Some(d) = output_dir {
6398 d
6399 } else {
6400 let reg = state.registry.lock().await;
6401 reg.find_by_run_id(&run_id)
6402 .map(|e| recover_artifacts_from_registry(e).output_dir)
6403 .unwrap_or_default()
6404 };
6405
6406 {
6408 let mut reg = state.registry.lock().await;
6409 reg.entries.retain(|e| e.run_id != run_id);
6410 let _ = reg.save(&state.registry_path);
6411 }
6412
6413 if output_dir.exists() {
6416 match tokio::fs::remove_dir_all(&output_dir).await {
6417 Ok(()) => {}
6418 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6419 Err(e) => {
6420 return (
6421 StatusCode::INTERNAL_SERVER_ERROR,
6422 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6423 )
6424 .into_response();
6425 }
6426 }
6427 }
6428
6429 StatusCode::NO_CONTENT.into_response()
6430}
6431
6432async fn cleanup_runs_handler(
6437 State(state): State<AppState>,
6438 Json(body): Json<serde_json::Value>,
6439) -> Response {
6440 let days = body
6441 .get("older_than_days")
6442 .and_then(serde_json::Value::as_u64)
6443 .unwrap_or(30)
6444 .max(1);
6445
6446 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6447
6448 let expired: Vec<(String, PathBuf)> = {
6450 let reg = state.registry.lock().await;
6451 reg.entries
6452 .iter()
6453 .filter(|e| e.timestamp_utc < cutoff)
6454 .map(|e| {
6455 let arts = recover_artifacts_from_registry(e);
6456 (e.run_id.clone(), arts.output_dir)
6457 })
6458 .collect()
6459 };
6460
6461 let mut deleted = 0usize;
6462 for (run_id, output_dir) in &expired {
6463 state.artifacts.lock().await.remove(run_id);
6465 if output_dir.exists() {
6467 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6468 eprintln!(
6469 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6470 output_dir.display()
6471 );
6472 continue;
6473 }
6474 }
6475 deleted += 1;
6476 }
6477
6478 let expired_ids: std::collections::HashSet<&str> =
6480 expired.iter().map(|(id, _)| id.as_str()).collect();
6481 {
6482 let mut reg = state.registry.lock().await;
6483 reg.entries
6484 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6485 let _ = reg.save(&state.registry_path);
6486 }
6487
6488 Json(serde_json::json!({ "deleted": deleted })).into_response()
6489}
6490
6491fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6494 tokio::spawn(async move {
6495 loop {
6496 let interval_secs = {
6497 let store = state.cleanup_policy.lock().await;
6498 match &store.policy {
6499 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6500 _ => break,
6501 }
6502 };
6503 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6504 let n = run_auto_cleanup(&state).await;
6505 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6506 }
6507 })
6508}
6509
6510fn collect_runs_to_delete(
6511 reg: &ScanRegistry,
6512 max_age_days: Option<u32>,
6513 max_run_count: Option<u32>,
6514) -> std::collections::HashSet<String> {
6515 let mut to_delete = std::collections::HashSet::new();
6516 if let Some(days) = max_age_days {
6517 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6518 for e in ®.entries {
6519 if e.timestamp_utc < cutoff {
6520 to_delete.insert(e.run_id.clone());
6521 }
6522 }
6523 }
6524 if let Some(max_count) = max_run_count {
6525 for e in reg.entries.iter().skip(max_count as usize) {
6527 to_delete.insert(e.run_id.clone());
6528 }
6529 }
6530 to_delete
6531}
6532
6533async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6534 let output_dir = {
6535 let mut cache = state.artifacts.lock().await;
6536 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6537 cache.remove(run_id);
6538 d
6539 };
6540 let output_dir = if let Some(d) = output_dir {
6541 d
6542 } else {
6543 let reg = state.registry.lock().await;
6544 reg.find_by_run_id(run_id)
6545 .map(|e| recover_artifacts_from_registry(e).output_dir)
6546 .unwrap_or_default()
6547 };
6548 if output_dir.exists() {
6549 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6550 }
6551}
6552
6553async fn run_auto_cleanup(state: &AppState) -> u32 {
6557 let (max_age_days, max_run_count) = {
6558 let store = state.cleanup_policy.lock().await;
6559 match &store.policy {
6560 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6561 _ => return 0,
6562 }
6563 };
6564
6565 let to_delete = {
6566 let reg = state.registry.lock().await;
6567 collect_runs_to_delete(®, max_age_days, max_run_count)
6568 };
6569
6570 for run_id in &to_delete {
6571 delete_run_artifacts(state, run_id).await;
6572 }
6573
6574 if !to_delete.is_empty() {
6576 let mut reg = state.registry.lock().await;
6577 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6578 let _ = reg.save(&state.registry_path);
6579 }
6580
6581 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6582 {
6583 let mut store = state.cleanup_policy.lock().await;
6584 store.last_run_at = Some(chrono::Utc::now());
6585 store.last_run_deleted = Some(deleted);
6586 let _ = store.save(&state.cleanup_policy_path);
6587 }
6588 deleted
6589}
6590
6591async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6595 let store = state.cleanup_policy.lock().await;
6596 Json(serde_json::json!({
6597 "policy": store.policy,
6598 "last_run_at": store.last_run_at,
6599 "last_run_deleted": store.last_run_deleted,
6600 }))
6601 .into_response()
6602}
6603
6604async fn api_save_cleanup_policy(
6606 State(state): State<AppState>,
6607 Json(body): Json<CleanupPolicy>,
6608) -> Response {
6609 {
6611 let mut handle = state.cleanup_task_handle.lock().await;
6612 if let Some(h) = handle.take() {
6613 h.abort();
6614 }
6615 }
6616 {
6617 let mut store = state.cleanup_policy.lock().await;
6618 store.policy = Some(body.clone());
6619 if let Err(e) = store.save(&state.cleanup_policy_path) {
6620 return (
6621 StatusCode::INTERNAL_SERVER_ERROR,
6622 Json(serde_json::json!({"error": e.to_string()})),
6623 )
6624 .into_response();
6625 }
6626 }
6627 if body.enabled {
6628 let handle = spawn_cleanup_policy_task(state.clone());
6629 *state.cleanup_task_handle.lock().await = Some(handle);
6630 }
6631 StatusCode::NO_CONTENT.into_response()
6632}
6633
6634async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6636 let deleted = run_auto_cleanup(&state).await;
6637 Json(serde_json::json!({ "deleted": deleted })).into_response()
6638}
6639
6640async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6642 {
6643 let mut handle = state.cleanup_task_handle.lock().await;
6644 if let Some(h) = handle.take() {
6645 h.abort();
6646 }
6647 }
6648 {
6649 let mut store = state.cleanup_policy.lock().await;
6650 store.policy = None;
6651 let _ = store.save(&state.cleanup_policy_path);
6652 }
6653 StatusCode::NO_CONTENT.into_response()
6654}
6655
6656fn swap_inline_chart_js_for_static(html: String) -> String {
6662 let Some(head_end) = html.find("</head>") else {
6663 return html;
6664 };
6665 let Some(script_start) = html[..head_end].rfind("<script") else {
6666 return html;
6667 };
6668 let Some(close_offset) = html[script_start..].find("</script>") else {
6669 return html;
6670 };
6671 let block_end = script_start + close_offset + "</script>".len();
6672 format!(
6673 "{}<script src=\"/static/chart-report.js\"></script>{}",
6674 &html[..script_start],
6675 &html[block_end..]
6676 )
6677}
6678
6679fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6681 let Some(start) = html.find("nonce=\"") else {
6683 return html
6687 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6688 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6689 };
6690 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
6692 return html.to_owned();
6693 };
6694 let old_nonce = &html[value_start..value_start + end_offset];
6695 html.replace(
6696 &format!("nonce=\"{old_nonce}\""),
6697 &format!("nonce=\"{new_nonce}\""),
6698 )
6699}
6700
6701fn serve_html_artifact(
6702 path: &Path,
6703 wants_download: bool,
6704 csp_nonce: &str,
6705 run_id: &str,
6706 server_mode: bool,
6707) -> Response {
6708 match fs::read_to_string(path) {
6709 Ok(raw) => {
6710 let content = patch_html_nonce(&raw, csp_nonce);
6712 if wants_download {
6713 (
6715 [
6716 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6717 (
6718 header::CONTENT_DISPOSITION,
6719 "attachment; filename=report.html",
6720 ),
6721 ],
6722 content,
6723 )
6724 .into_response()
6725 } else {
6726 Html(swap_inline_chart_js_for_static(content)).into_response()
6729 }
6730 }
6731 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6732 let filename = path.file_name().map_or_else(
6733 || "report.html".to_string(),
6734 |n| n.to_string_lossy().into_owned(),
6735 );
6736 let html = LocateFileTemplate {
6737 run_id: run_id.to_owned(),
6738 artifact_type: "html".to_string(),
6739 expected_filename: filename,
6740 server_mode,
6741 csp_nonce: csp_nonce.to_owned(),
6742 version: env!("CARGO_PKG_VERSION"),
6743 }
6744 .render()
6745 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6746 (StatusCode::NOT_FOUND, Html(html)).into_response()
6747 }
6748 Err(err) => {
6749 let filename = path.file_name().map_or_else(
6750 || "report.html".to_string(),
6751 |n| n.to_string_lossy().into_owned(),
6752 );
6753 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6754 let html = ErrorTemplate {
6755 message: msg,
6756 last_report_url: Some("/view-reports".to_string()),
6757 last_report_label: Some("View Reports".to_string()),
6758 run_id: None,
6759 error_code: Some(404),
6760 csp_nonce: csp_nonce.to_owned(),
6761 version: env!("CARGO_PKG_VERSION"),
6762 }
6763 .render()
6764 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6765 (StatusCode::NOT_FOUND, Html(html)).into_response()
6766 }
6767 }
6768}
6769
6770fn serve_pdf_artifact(
6772 path: &Path,
6773 report_title: &str,
6774 run_id: &str,
6775 wants_download: bool,
6776 csp_nonce: &str,
6777) -> Response {
6778 match fs::read(path) {
6779 Ok(bytes) => {
6780 let filename = build_pdf_filename(report_title, run_id);
6781 let disposition = if wants_download {
6782 format!("attachment; filename=\"{filename}\"")
6783 } else {
6784 format!("inline; filename=\"{filename}\"")
6785 };
6786 (
6787 [
6788 (header::CONTENT_TYPE, "application/pdf".to_string()),
6789 (header::CONTENT_DISPOSITION, disposition),
6790 ],
6791 bytes,
6792 )
6793 .into_response()
6794 }
6795 Err(err) => {
6796 let filename = path.file_name().map_or_else(
6797 || "report.pdf".to_string(),
6798 |n| n.to_string_lossy().into_owned(),
6799 );
6800 let msg = format!(
6801 "PDF report '{filename}' could not be read.\n\n\
6802 Error: {err}\n\n\
6803 If you moved or renamed the output folder, the stored path is now stale. \
6804 Use 'Open PDF folder' from the results page to browse the output directory."
6805 );
6806 let html = ErrorTemplate {
6807 message: msg,
6808 last_report_url: Some("/view-reports".to_string()),
6809 last_report_label: Some("View Reports".to_string()),
6810 run_id: Some(run_id.to_owned()),
6811 error_code: Some(404),
6812 csp_nonce: csp_nonce.to_owned(),
6813 version: env!("CARGO_PKG_VERSION"),
6814 }
6815 .render()
6816 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6817 (StatusCode::NOT_FOUND, Html(html)).into_response()
6818 }
6819 }
6820}
6821
6822fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6824 match fs::read(path) {
6825 Ok(bytes) => {
6826 if wants_download {
6827 (
6828 [
6829 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6830 (
6831 header::CONTENT_DISPOSITION,
6832 "attachment; filename=result.json",
6833 ),
6834 ],
6835 bytes,
6836 )
6837 .into_response()
6838 } else {
6839 (
6840 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6841 bytes,
6842 )
6843 .into_response()
6844 }
6845 }
6846 Err(err) => {
6847 let filename = path.file_name().map_or_else(
6848 || "result.json".to_string(),
6849 |n| n.to_string_lossy().into_owned(),
6850 );
6851 let msg = format!(
6852 "JSON result '{filename}' could not be read.\n\n\
6853 Error: {err}\n\n\
6854 If you moved or renamed the output folder, the stored path is now stale. \
6855 Use 'Open JSON folder' from the results page to browse the output directory."
6856 );
6857 let html = ErrorTemplate {
6858 message: msg,
6859 last_report_url: Some("/view-reports".to_string()),
6860 last_report_label: Some("View Reports".to_string()),
6861 run_id: None,
6862 error_code: Some(404),
6863 csp_nonce: csp_nonce.to_owned(),
6864 version: env!("CARGO_PKG_VERSION"),
6865 }
6866 .render()
6867 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6868 (StatusCode::NOT_FOUND, Html(html)).into_response()
6869 }
6870 }
6871}
6872
6873fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6875 let output_dir = entry
6878 .html_path
6879 .as_ref()
6880 .or(entry.json_path.as_ref())
6881 .or(entry.pdf_path.as_ref())
6882 .or(entry.csv_path.as_ref())
6883 .or(entry.xlsx_path.as_ref())
6884 .and_then(|p| {
6885 let parent = p.parent()?;
6886 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6887 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6889 parent.parent().map(PathBuf::from)
6890 } else {
6891 Some(parent.to_path_buf())
6892 }
6893 })
6894 .unwrap_or_default();
6895 let pdf_path = entry.pdf_path.clone().or_else(|| {
6898 let candidate = output_dir.join("report.pdf");
6899 candidate.exists().then_some(candidate)
6900 });
6901 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6905 for dir in &[output_dir.join("excel"), output_dir.clone()] {
6907 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6908 entries
6909 .filter_map(std::result::Result::ok)
6910 .find(|e| {
6911 let n = e.file_name();
6912 let n = n.to_string_lossy();
6913 n.starts_with("report_") && n.ends_with(ext)
6914 })
6915 .map(|e| e.path())
6916 }) {
6917 return Some(p);
6918 }
6919 }
6920 None
6921 };
6922
6923 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6924 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6925 RunArtifacts {
6926 output_dir: output_dir.clone(),
6927 html_path: entry.html_path.clone(),
6928 pdf_path,
6929 json_path: entry.json_path.clone(),
6930 csv_path,
6931 xlsx_path,
6932 scan_config_path: find_scan_config_in_dir(&output_dir),
6933 report_title: entry.project_label.clone(),
6934 result_context: RunResultContext::default(),
6935 }
6936}
6937
6938#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
6940 state: &AppState,
6941 run_id: &str,
6942 csp_nonce: &str,
6943) -> Result<RunArtifacts, Response> {
6944 let cached = state.artifacts.lock().await.get(run_id).cloned();
6945 if let Some(a) = cached {
6946 return Ok(a);
6947 }
6948 let reg = state.registry.lock().await;
6949 if let Some(entry) = reg.find_by_run_id(run_id) {
6950 return Ok(recover_artifacts_from_registry(entry));
6951 }
6952 drop(reg);
6953 let short_id = &run_id[..run_id.len().min(8)];
6954 let hint = if matches!(
6955 run_id,
6956 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6957 ) {
6958 format!(
6959 " The URL format appears to be reversed \u{2014} \
6960 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6961 Use the View Reports page to navigate to your scan."
6962 )
6963 } else {
6964 " The report may have been deleted or the report directory moved. \
6965 Use View Reports to browse your scan history."
6966 .to_string()
6967 };
6968 let error_html = ErrorTemplate {
6969 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6970 last_report_url: Some("/view-reports".to_string()),
6971 last_report_label: Some("View Reports".to_string()),
6972 run_id: None,
6973 error_code: Some(404),
6974 csp_nonce: csp_nonce.to_owned(),
6975 version: env!("CARGO_PKG_VERSION"),
6976 }
6977 .render()
6978 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6979 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6980}
6981
6982async fn resolve_or_queue_pdf(
6987 state: &AppState,
6988 pdf_path: Option<PathBuf>,
6989 json_path: Option<PathBuf>,
6990 output_dir: PathBuf,
6991 run_id: &str,
6992 report_title: &str,
6993 csp_nonce: &str,
6994) -> Result<PathBuf, Response> {
6995 if let Some(p) = pdf_path {
6996 return Ok(p);
6997 }
6998 let Some(json_src) = json_path.filter(|p| p.exists()) else {
6999 let msg = "PDF report was not generated for this run. \
7000 Re-run the analysis with PDF output enabled."
7001 .to_string();
7002 let html = ErrorTemplate {
7003 message: msg,
7004 last_report_url: Some(format!("/runs/html/{run_id}")),
7005 last_report_label: Some("View HTML Report".to_string()),
7006 run_id: Some(run_id.to_string()),
7007 error_code: Some(404),
7008 csp_nonce: csp_nonce.to_string(),
7009 version: env!("CARGO_PKG_VERSION"),
7010 }
7011 .render()
7012 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7013 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7014 };
7015 let pdf_filename = build_pdf_filename(report_title, run_id);
7016 let pdf_dest = output_dir.join(&pdf_filename);
7017 if !pdf_dest.exists() {
7018 {
7020 let mut map = state.artifacts.lock().await;
7021 if let Some(entry) = map.get_mut(run_id) {
7022 entry.pdf_path = Some(pdf_dest.clone());
7023 }
7024 }
7025 {
7026 let mut reg = state.registry.lock().await;
7027 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7028 e.pdf_path = Some(pdf_dest.clone());
7029 }
7030 let _ = reg.save(&state.registry_path);
7031 }
7032 spawn_native_pdf_background(
7033 json_src,
7034 pdf_dest.clone(),
7035 run_id.to_string(),
7036 state.artifacts.clone(),
7037 );
7038 }
7039 Ok(pdf_dest)
7040}
7041
7042fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7044 let html = format!(
7045 "<!doctype html><html lang=\"en\"><head>\
7046 <meta charset=utf-8>\
7047 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7048 <meta http-equiv=\"refresh\" content=\"5\">\
7049 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7050 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7051 <style nonce=\"{csp_nonce}\">\
7052 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7053 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7054 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7055 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7056 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7057 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7058 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7059 background:var(--bg);color:var(--text);}}\
7060 .top-nav{{position:sticky;top:0;z-index:30;\
7061 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7062 border-bottom:1px solid rgba(255,255,255,0.12);\
7063 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7064 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7065 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7066 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7067 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7068 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7069 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7070 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7071 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7072 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7073 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7074 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7075 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7076 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7077 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7078 justify-content:center;min-height:38px;border-radius:999px;\
7079 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7080 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7081 .theme-toggle .icon-sun{{display:none;}}\
7082 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7083 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7084 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7085 display:flex;align-items:center;justify-content:center;\
7086 min-height:calc(100vh - 56px);}}\
7087 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7088 .panel{{background:var(--surface);border:1px solid var(--line);\
7089 border-radius:var(--radius);box-shadow:var(--shadow);\
7090 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7091 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7092 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7093 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7094 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7095 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7096 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7097 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7098 min-height:42px;padding:0 20px;border-radius:14px;\
7099 border:1px solid var(--line-strong);text-decoration:none;\
7100 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7101 .back-link:hover{{background:var(--line);}}\
7102 </style></head>\
7103 <body>\
7104 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7105 <a class=\"brand\" href=\"/\">\
7106 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7107 <div class=\"brand-copy\">\
7108 <div class=\"brand-title\">OxideSLOC</div>\
7109 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7110 </div>\
7111 </a>\
7112 <div class=\"nav-right\">\
7113 <a class=\"nav-pill\" href=\"/\">Home</a>\
7114 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7115 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7116 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7117 <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>\
7118 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7119 <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>\
7120 </button>\
7121 </div>\
7122 </div></div>\
7123 <div class=\"page\"><div class=\"panel\">\
7124 <div class=\"spin-ring\"></div>\
7125 <h1>Generating PDF\u{2026}</h1>\
7126 <p>The PDF is being generated from the scan results.<br>\
7127 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7128 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7129 </div></div>\
7130 <script nonce=\"{csp_nonce}\">\
7131 (function(){{\
7132 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7133 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7134 var t=document.getElementById(\"theme-toggle\");\
7135 if(t)t.addEventListener(\"click\",function(){{\
7136 var d=b.classList.toggle(\"dark-theme\");\
7137 localStorage.setItem(k,d?\"dark\":\"light\");\
7138 }});\
7139 }})();\
7140 </script>\
7141 </body></html>"
7142 );
7143 Html(html).into_response()
7144}
7145
7146fn render_error_artifact_html(
7148 message: String,
7149 last_report_url: Option<String>,
7150 last_report_label: Option<String>,
7151 run_id: Option<String>,
7152 error_code: Option<u16>,
7153 csp_nonce: &str,
7154) -> String {
7155 ErrorTemplate {
7156 message,
7157 last_report_url,
7158 last_report_label,
7159 run_id,
7160 error_code,
7161 csp_nonce: csp_nonce.to_owned(),
7162 version: env!("CARGO_PKG_VERSION"),
7163 }
7164 .render()
7165 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7166}
7167
7168fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7170 fs::read(path).map_or_else(
7171 |_| StatusCode::NOT_FOUND.into_response(),
7172 |bytes| {
7173 let filename = path.file_name().map_or_else(
7174 || fallback_filename.to_string(),
7175 |n| n.to_string_lossy().into_owned(),
7176 );
7177 (
7178 [
7179 (header::CONTENT_TYPE, content_type.to_string()),
7180 (
7181 header::CONTENT_DISPOSITION,
7182 format!("attachment; filename=\"{filename}\""),
7183 ),
7184 ],
7185 bytes,
7186 )
7187 .into_response()
7188 },
7189 )
7190}
7191
7192fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7193 let Some(path) = csv_path else {
7194 let html = render_error_artifact_html(
7195 "CSV report was not generated for this run, or was not recorded in \
7196 the scan registry."
7197 .to_string(),
7198 Some(format!("/runs/html/{run_id}")),
7199 Some("View HTML Report".to_string()),
7200 Some(run_id.to_string()),
7201 Some(404),
7202 csp_nonce,
7203 );
7204 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7205 };
7206 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7207}
7208
7209fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7210 let Some(path) = xlsx_path else {
7211 let html = render_error_artifact_html(
7212 "Excel report was not generated for this run, or was not recorded in \
7213 the scan registry."
7214 .to_string(),
7215 Some(format!("/runs/html/{run_id}")),
7216 Some("View HTML Report".to_string()),
7217 Some(run_id.to_string()),
7218 Some(404),
7219 csp_nonce,
7220 );
7221 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7222 };
7223 serve_binary_download(
7224 &path,
7225 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7226 "report.xlsx",
7227 )
7228}
7229
7230fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7231 let path = artifact_set
7232 .scan_config_path
7233 .as_deref()
7234 .map(std::path::Path::to_path_buf)
7235 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7236 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7237 fs::read(&path).map_or_else(
7238 |_| StatusCode::NOT_FOUND.into_response(),
7239 |bytes| {
7240 (
7241 [
7242 (
7243 header::CONTENT_TYPE,
7244 "application/json; charset=utf-8".to_string(),
7245 ),
7246 (
7247 header::CONTENT_DISPOSITION,
7248 "attachment; filename=\"scan-config.json\"".to_string(),
7249 ),
7250 ],
7251 bytes,
7252 )
7253 .into_response()
7254 },
7255 )
7256}
7257
7258async fn serve_submodule_pdf_arm(
7263 artifact: &str,
7264 artifact_set: RunArtifacts,
7265 wants_download: bool,
7266 run_id: &str,
7267 csp_nonce: &str,
7268) -> Response {
7269 let base = artifact.trim_end_matches("_pdf");
7271 let sub_dir = artifact_set.output_dir.join("submodules");
7272 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7273
7274 if !pdf_path.exists() {
7275 let derived_safe = base.trim_start_matches("sub_");
7277 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7278 let parent_run = read_json(jp).ok()?;
7279 let sub = parent_run
7280 .submodule_summaries
7281 .iter()
7282 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7283 .clone();
7284 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7285 Some((parent_run, sub, parent_path))
7286 });
7287
7288 if let Some((parent_run, sub, parent_path)) = rebuilt {
7289 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7290 let pp = pdf_path.clone();
7291 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7292 }
7293 }
7294
7295 if !pdf_path.exists() {
7296 let html = render_error_artifact_html(
7297 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7298 enabled."
7299 .to_string(),
7300 Some("/view-reports".to_string()),
7301 Some("View Reports".to_string()),
7302 Some(run_id.to_string()),
7303 Some(404),
7304 csp_nonce,
7305 );
7306 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7307 }
7308
7309 serve_pdf_artifact(
7310 &pdf_path,
7311 &artifact_set.report_title,
7312 run_id,
7313 wants_download,
7314 csp_nonce,
7315 )
7316}
7317
7318fn serve_submodule_arm(
7319 artifact: &str,
7320 artifact_set: &RunArtifacts,
7321 wants_download: bool,
7322 csp_nonce: &str,
7323 run_id: &str,
7324 server_mode: bool,
7325) -> Response {
7326 if artifact.len() > 128
7327 || !artifact
7328 .chars()
7329 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7330 {
7331 return StatusCode::BAD_REQUEST.into_response();
7332 }
7333 let filename = format!("{artifact}.html");
7334 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7336 let path = if new_layout.exists() {
7337 new_layout
7338 } else {
7339 artifact_set.output_dir.join(&filename)
7340 };
7341 if !path.exists() {
7342 let html = render_error_artifact_html(
7343 format!(
7344 "Sub-report '{artifact}' was not found in the run directory.\n\
7345 Re-run the analysis with 'Detect and separate git submodules' \
7346 and HTML output enabled."
7347 ),
7348 Some("/view-reports".to_string()),
7349 Some("View Reports".to_string()),
7350 Some(run_id.to_string()),
7351 Some(404),
7352 csp_nonce,
7353 );
7354 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7355 }
7356 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7357}
7358
7359async fn serve_pdf_arm(
7360 state: &AppState,
7361 artifact_set: RunArtifacts,
7362 wants_download: bool,
7363 run_id: &str,
7364 csp_nonce: &str,
7365) -> Response {
7366 let report_title = artifact_set.report_title.clone();
7367 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7368 let stale_html_name = artifact_set
7369 .html_path
7370 .as_deref()
7371 .and_then(|p| p.file_name())
7372 .map(|n| n.to_string_lossy().into_owned());
7373 let path = match resolve_or_queue_pdf(
7374 state,
7375 artifact_set.pdf_path,
7376 artifact_set.json_path.clone(),
7377 artifact_set.output_dir.clone(),
7378 run_id,
7379 &report_title,
7380 csp_nonce,
7381 )
7382 .await
7383 {
7384 Ok(p) => p,
7385 Err(r) => return r,
7386 };
7387 if !path.exists() {
7388 if had_pdf_in_registry {
7392 if let Some(expected_filename) = stale_html_name {
7393 let html = LocateFileTemplate {
7394 run_id: run_id.to_string(),
7395 artifact_type: "pdf".to_string(),
7396 expected_filename,
7397 server_mode: state.server_mode,
7398 csp_nonce: csp_nonce.to_string(),
7399 version: env!("CARGO_PKG_VERSION"),
7400 }
7401 .render()
7402 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7403 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7404 }
7405 }
7406 return pdf_generating_response(run_id, csp_nonce);
7407 }
7408 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7409}
7410
7411async fn artifact_handler(
7412 State(state): State<AppState>,
7413 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7414 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7415 Query(query): Query<ArtifactQuery>,
7416) -> Response {
7417 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7418 Ok(a) => a,
7419 Err(r) => return r,
7420 };
7421
7422 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7423
7424 match artifact.as_str() {
7425 "html" => {
7426 let Some(path) = artifact_set.html_path else {
7427 return StatusCode::NOT_FOUND.into_response();
7428 };
7429 serve_html_artifact(
7430 &path,
7431 wants_download,
7432 &csp_nonce,
7433 &run_id,
7434 state.server_mode,
7435 )
7436 }
7437 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7438 "json" => {
7439 let Some(path) = artifact_set.json_path else {
7440 let html = render_error_artifact_html(
7441 "JSON result was not generated for this run, or was not recorded in \
7442 the scan registry. Re-run the analysis with JSON output enabled."
7443 .to_string(),
7444 Some("/view-reports".to_string()),
7445 Some("View Reports".to_string()),
7446 Some(run_id.clone()),
7447 Some(404),
7448 &csp_nonce,
7449 );
7450 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7451 };
7452 serve_json_artifact(&path, wants_download, &csp_nonce)
7453 }
7454 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7455 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7456 "scan-config" => serve_scan_config_arm(&artifact_set),
7457 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7458 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7459 .await
7460 }
7461 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7462 &artifact,
7463 &artifact_set,
7464 wants_download,
7465 &csp_nonce,
7466 &run_id,
7467 state.server_mode,
7468 ),
7469 _ => StatusCode::NOT_FOUND.into_response(),
7470 }
7471}
7472
7473struct SubmoduleLinkRow {
7476 name: String,
7477 url: String,
7478}
7479
7480struct HistoryEntryRow {
7481 run_id: String,
7482 run_id_short: String,
7483 timestamp: String,
7484 timestamp_utc_ms: i64,
7485 project_label: String,
7486 project_path: String,
7487 files_analyzed: u64,
7488 files_skipped: u64,
7489 code_lines: u64,
7490 comment_lines: u64,
7491 blank_lines: u64,
7492 total_physical_lines: u64,
7493 functions: u64,
7494 classes: u64,
7495 variables: u64,
7496 imports: u64,
7497 test_count: u64,
7498 git_branch: String,
7499 git_commit: String,
7500 git_commit_long: String,
7502 has_html: bool,
7503 has_json: bool,
7504 has_pdf: bool,
7505 submodule_links: Vec<SubmoduleLinkRow>,
7506 submodule_names_csv: String,
7508}
7509
7510fn nth_weekday_of_month(
7512 year: i32,
7513 month: u32,
7514 weekday: chrono::Weekday,
7515 n: u32,
7516) -> chrono::NaiveDate {
7517 use chrono::Datelike;
7518 let mut count = 0u32;
7519 let mut day = 1u32;
7520 loop {
7521 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7522 if d.weekday() == weekday {
7523 count += 1;
7524 if count == n {
7525 return d;
7526 }
7527 }
7528 day += 1;
7529 }
7530}
7531
7532fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7536 use chrono::{Datelike, TimeZone};
7537 let year = dt.year();
7538 let dst_start = chrono::Utc.from_utc_datetime(
7539 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7540 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7541 );
7542 let dst_end = chrono::Utc.from_utc_datetime(
7543 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7544 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7545 );
7546 dt >= dst_start && dt < dst_end
7547}
7548
7549fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7550 if is_pacific_dst(dt) {
7551 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7552 .format("%Y-%m-%d %H:%M PDT")
7553 .to_string()
7554 } else {
7555 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7556 .format("%Y-%m-%d %H:%M PST")
7557 .to_string()
7558 }
7559}
7560
7561fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7563 let (offset, tz) = if is_pacific_dst(dt) {
7564 (
7565 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7566 "PDT",
7567 )
7568 } else {
7569 (
7570 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7571 "PST",
7572 )
7573 };
7574 format!(
7575 "{} {tz}",
7576 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7577 )
7578}
7579
7580fn fmt_git_date(iso: &str) -> Option<String> {
7581 chrono::DateTime::parse_from_rfc3339(iso)
7582 .ok()
7583 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7584}
7585
7586fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7595 use std::io::{Read, Seek, SeekFrom};
7596 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
7598 return None;
7599 }
7600 let len = std::fs::metadata(path).ok()?.len();
7601 let start = len.saturating_sub(TAIL);
7602 let mut file = std::fs::File::open(path).ok()?;
7603 file.seek(SeekFrom::Start(start)).ok()?;
7604 let mut buf = Vec::new();
7605 file.read_to_end(&mut buf).ok()?;
7606 let text = String::from_utf8_lossy(&buf);
7607 let short_lower = short.to_ascii_lowercase();
7608 let key = "\"git_commit_long\"";
7609 let mut found: Option<String> = None;
7610 let mut cursor = 0usize;
7611 while let Some(idx) = text[cursor..].find(key) {
7612 let after_key = cursor + idx + key.len();
7613 cursor = after_key;
7614 let rest = &text[after_key..];
7615 let Some(colon) = rest.find(':') else { break };
7616 let value_region = rest[colon + 1..].trim_start();
7617 if let Some(open) = value_region.strip_prefix('"') {
7619 if let Some(close) = open.find('"') {
7620 let val = &open[..close];
7621 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7622 found = Some(val.to_string());
7623 }
7624 }
7625 }
7626 }
7627 found
7628}
7629
7630fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7631 reg.entries
7632 .iter()
7633 .map(|e| {
7634 let submodule_links = {
7635 let mut links: Vec<SubmoduleLinkRow> = vec![];
7636 let sub_dir = e
7637 .html_path
7638 .as_ref()
7639 .and_then(|p| p.parent())
7640 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7641 if let Some(dir) = sub_dir {
7642 if let Ok(rd) = std::fs::read_dir(dir) {
7643 for entry_res in rd.flatten() {
7644 let fname = entry_res.file_name();
7645 let fname_str = fname.to_string_lossy();
7646 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7647 let stem = &fname_str[..fname_str.len() - 5];
7648 let display = stem[4..].replace('-', " ");
7649 links.push(SubmoduleLinkRow {
7650 name: display,
7651 url: format!("/runs/{stem}/{}", e.run_id),
7652 });
7653 }
7654 }
7655 }
7656 }
7657 links.sort_by(|a, b| a.name.cmp(&b.name));
7658 links
7659 };
7660 let submodule_names_csv = submodule_links
7661 .iter()
7662 .map(|l| l.name.as_str())
7663 .collect::<Vec<_>>()
7664 .join(",");
7665 HistoryEntryRow {
7666 run_id: e.run_id.clone(),
7667 run_id_short: e
7668 .run_id
7669 .split('-')
7670 .next_back()
7671 .unwrap_or(&e.run_id)
7672 .chars()
7673 .take(7)
7674 .collect(),
7675 timestamp: fmt_la_time(e.timestamp_utc),
7676 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7677 project_label: e.project_label.clone(),
7678 project_path: e
7679 .input_roots
7680 .first()
7681 .map(|s| sanitize_path_str(s))
7682 .unwrap_or_default(),
7683 files_analyzed: e.summary.files_analyzed,
7684 files_skipped: e.summary.files_skipped,
7685 code_lines: e.summary.code_lines,
7686 comment_lines: e.summary.comment_lines,
7687 blank_lines: e.summary.blank_lines,
7688 total_physical_lines: e.summary.total_physical_lines,
7689 functions: e.summary.functions,
7690 classes: e.summary.classes,
7691 variables: e.summary.variables,
7692 imports: e.summary.imports,
7693 test_count: e.summary.test_count,
7694 git_branch: e.git_branch.clone().unwrap_or_default(),
7695 git_commit: e.git_commit.clone().unwrap_or_default(),
7696 git_commit_long: {
7697 let short = e.git_commit.clone().unwrap_or_default();
7698 e.git_commit_long
7699 .clone()
7700 .filter(|s| !s.is_empty())
7701 .or_else(|| {
7702 e.json_path
7703 .as_ref()
7704 .and_then(|p| extract_long_commit_from_json(p, &short))
7705 })
7706 .unwrap_or(short)
7707 },
7708 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7709 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7710 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7711 submodule_links,
7712 submodule_names_csv,
7713 }
7714 })
7715 .collect()
7716}
7717
7718#[derive(Deserialize, Default)]
7719struct HistoryQuery {
7720 linked: Option<String>,
7721 error: Option<String>,
7722}
7723
7724async fn history_handler(
7725 State(state): State<AppState>,
7726 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727 Query(query): Query<HistoryQuery>,
7728) -> impl IntoResponse {
7729 auto_scan_watched_dirs(&state).await;
7731 let watched_dirs: Vec<String> = {
7732 let wd = state.watched_dirs.lock().await;
7733 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7734 };
7735 let mut entries = {
7736 let reg = state.registry.lock().await;
7737 make_history_rows(®)
7738 };
7739 entries.retain(|e| e.has_html);
7740 let total_scans = entries.len();
7741 let linked_count = query
7742 .linked
7743 .as_deref()
7744 .and_then(|s| s.parse::<usize>().ok())
7745 .unwrap_or(0);
7746 let browse_error = query.error.filter(|s| !s.is_empty());
7747 let template = HistoryTemplate {
7748 version: env!("CARGO_PKG_VERSION"),
7749 entries,
7750 total_scans,
7751 linked_count,
7752 browse_error,
7753 watched_dirs,
7754 csp_nonce,
7755 server_mode: state.server_mode,
7756 };
7757 Html(
7758 template
7759 .render()
7760 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7761 )
7762 .into_response()
7763}
7764
7765async fn compare_select_handler(
7766 State(state): State<AppState>,
7767 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7768) -> impl IntoResponse {
7769 auto_scan_watched_dirs(&state).await;
7770 let watched_dirs: Vec<String> = {
7771 let wd = state.watched_dirs.lock().await;
7772 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7773 };
7774 let mut entries = {
7775 let reg = state.registry.lock().await;
7776 make_history_rows(®)
7777 };
7778 entries.retain(|e| e.has_json);
7779 let total_scans = entries.len();
7780 let template = CompareSelectTemplate {
7781 version: env!("CARGO_PKG_VERSION"),
7782 entries,
7783 total_scans,
7784 watched_dirs,
7785 csp_nonce,
7786 server_mode: state.server_mode,
7787 };
7788 Html(
7789 template
7790 .render()
7791 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7792 )
7793 .into_response()
7794}
7795
7796#[derive(Deserialize, Default)]
7799struct CompareQuery {
7800 a: Option<String>,
7801 b: Option<String>,
7802 sub: Option<String>,
7804 scope: Option<String>,
7806}
7807
7808struct CompareFileDeltaRow {
7809 relative_path: String,
7810 language: String,
7811 status: String,
7812 baseline_code: i64,
7813 current_code: i64,
7814 baseline_code_display: String,
7815 current_code_display: String,
7816 code_delta_str: String,
7817 code_delta_class: String,
7818 comment_delta_str: String,
7819 comment_delta_class: String,
7820 total_delta_str: String,
7821 total_delta_class: String,
7822}
7823
7824fn recompute_summary_from_records(run: &mut AnalysisRun) {
7827 let mut totals = SummaryTotals::default();
7828 for r in &run.per_file_records {
7829 if r.language.is_some() {
7830 totals.files_analyzed += 1;
7831 }
7832 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7833 totals.code_lines += r.effective_counts.code_lines;
7834 totals.comment_lines += r.effective_counts.comment_lines;
7835 totals.blank_lines += r.effective_counts.blank_lines;
7836 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7837 totals.functions += r.raw_line_categories.functions;
7838 totals.classes += r.raw_line_categories.classes;
7839 totals.variables += r.raw_line_categories.variables;
7840 totals.imports += r.raw_line_categories.imports;
7841 totals.test_count += r.raw_line_categories.test_count;
7842 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7843 totals.test_suite_count += r.raw_line_categories.test_suite_count;
7844 if let Some(cov) = &r.coverage {
7845 totals.coverage_lines_found += u64::from(cov.lines_found);
7846 totals.coverage_lines_hit += u64::from(cov.lines_hit);
7847 totals.coverage_functions_found += u64::from(cov.functions_found);
7848 totals.coverage_functions_hit += u64::from(cov.functions_hit);
7849 totals.coverage_branches_found += u64::from(cov.branches_found);
7850 totals.coverage_branches_hit += u64::from(cov.branches_hit);
7851 }
7852 }
7853 totals.files_considered = totals.files_analyzed;
7854 run.summary_totals = totals;
7855}
7856
7857fn fmt_delta(n: i64) -> String {
7858 if n > 0 {
7859 format!("+{n}")
7860 } else {
7861 format!("{n}")
7862 }
7863}
7864
7865fn delta_class(n: i64) -> &'static str {
7866 use std::cmp::Ordering;
7867 match n.cmp(&0) {
7868 Ordering::Greater => "pos",
7869 Ordering::Less => "neg",
7870 Ordering::Equal => "zero",
7871 }
7872}
7873
7874#[allow(clippy::cast_precision_loss)]
7876fn fmt_pct(delta: i64, baseline: u64) -> String {
7877 if baseline == 0 {
7878 return "—".to_string();
7879 }
7880 #[allow(clippy::cast_precision_loss)]
7881 let pct = (delta as f64 / baseline as f64) * 100.0;
7882 if pct > 0.049 {
7883 format!("+{pct:.1}%")
7884 } else if pct < -0.049 {
7885 format!("{pct:.1}%")
7886 } else {
7887 "±0%".to_string()
7888 }
7889}
7890
7891fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7893 prev.map_or_else(
7894 || ("—".to_string(), "na"),
7895 |p| {
7896 #[allow(clippy::cast_possible_wrap)]
7897 let d = curr as i64 - p as i64;
7898 (fmt_delta(d), delta_class(d))
7899 },
7900 )
7901}
7902
7903#[allow(clippy::result_large_err)] fn load_scan_for_compare(
7905 json_path: &std::path::Path,
7906 scan_label: &str,
7907 run_id: &str,
7908 server_mode: bool,
7909 compare_url: &str,
7910 csp_nonce: &str,
7911) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7912 match read_json(json_path) {
7913 Ok(r) => Ok(r),
7914 Err(e) => {
7915 if server_mode {
7916 let html = ErrorTemplate {
7917 message: format!(
7918 "Could not load {scan_label} scan data. The scan output folder may have \
7919 been moved, renamed, or deleted. Re-running the analysis will create \
7920 fresh comparison data."
7921 ),
7922 last_report_url: Some("/compare-scans".to_string()),
7923 last_report_label: Some("Compare Scans".to_string()),
7924 run_id: Some(run_id.to_owned()),
7925 error_code: Some(404),
7926 csp_nonce: csp_nonce.to_owned(),
7927 version: env!("CARGO_PKG_VERSION"),
7928 }
7929 .render()
7930 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7931 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7932 }
7933 let msg = format!(
7934 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7935 json_path.display()
7936 );
7937 let folder_hint = output_folder_hint(json_path);
7938 Err(missing_scan_relocate_response(
7939 &msg,
7940 run_id,
7941 &folder_hint,
7942 compare_url,
7943 false,
7944 csp_nonce,
7945 ))
7946 }
7947 }
7948}
7949
7950struct ChurnStats {
7951 new_scope: bool,
7952 scope_flag: bool,
7953 churn_rate_str: String,
7954 churn_rate_class: String,
7955}
7956
7957fn compute_churn_stats(
7958 baseline_code: u64,
7959 current_code: u64,
7960 lines_added: i64,
7961 lines_removed: i64,
7962) -> ChurnStats {
7963 let new_scope = baseline_code == 0 && current_code > 0;
7964 #[allow(clippy::cast_precision_loss)]
7965 let churn_pct = if baseline_code > 0 {
7966 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7967 } else {
7968 0.0
7969 };
7970 #[allow(clippy::cast_precision_loss)]
7971 let scope_flag =
7972 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7973 let churn_rate_str = if new_scope {
7974 "New".to_string()
7975 } else if baseline_code > 0 {
7976 format!("{churn_pct:.1}%")
7977 } else {
7978 "—".to_string()
7979 };
7980 let churn_rate_class = if new_scope || churn_pct > 20.0 {
7981 "high".to_string()
7982 } else if churn_pct > 5.0 {
7983 "med".to_string()
7984 } else {
7985 "low".to_string()
7986 };
7987 ChurnStats {
7988 new_scope,
7989 scope_flag,
7990 churn_rate_str,
7991 churn_rate_class,
7992 }
7993}
7994
7995fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7999 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8000 if !has_data {
8001 return String::new();
8002 }
8003 let base_str = s
8004 .baseline_coverage_line_pct
8005 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8006 let curr_str = s
8007 .current_coverage_line_pct
8008 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8009 let (delta_str, cls) = match s.coverage_line_pct_delta {
8010 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8011 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8012 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8013 None => ("\u{2014}".into(), "zero"),
8014 };
8015 format!(
8016 r#"<div class="delta-card">
8017 <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>
8018 <div class="delta-card-label">Line coverage</div>
8019 <div class="delta-card-from">Before: {base_str}</div>
8020 <div class="delta-card-to">{curr_str}</div>
8021 <span class="delta-card-change {cls}">{delta_str}</span>
8022 </div>"#
8023 )
8024}
8025
8026#[allow(clippy::ref_option)]
8028fn narrow_run_pair_by_scope(
8029 mut baseline: AnalysisRun,
8030 mut current: AnalysisRun,
8031 active_sub: &Option<String>,
8032 super_scope: bool,
8033) -> (AnalysisRun, AnalysisRun) {
8034 if let Some(ref sub_name) = active_sub {
8035 baseline
8036 .per_file_records
8037 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8038 current
8039 .per_file_records
8040 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8041 recompute_summary_from_records(&mut baseline);
8042 recompute_summary_from_records(&mut current);
8043 } else if super_scope {
8044 baseline.per_file_records.retain(|f| f.submodule.is_none());
8045 current.per_file_records.retain(|f| f.submodule.is_none());
8046 recompute_summary_from_records(&mut baseline);
8047 recompute_summary_from_records(&mut current);
8048 }
8049 (baseline, current)
8050}
8051
8052#[allow(clippy::ref_option)]
8054fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8055 if let Some(ref sub_name) = active_sub {
8056 for run in runs.iter_mut() {
8057 run.per_file_records
8058 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8059 recompute_summary_from_records(run);
8060 }
8061 } else if super_scope {
8062 for run in runs.iter_mut() {
8063 run.per_file_records.retain(|f| f.submodule.is_none());
8064 recompute_summary_from_records(run);
8065 }
8066 }
8067}
8068
8069#[allow(clippy::too_many_lines)]
8070async fn compare_handler(
8071 State(state): State<AppState>,
8072 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8073 Query(query): Query<CompareQuery>,
8074) -> impl IntoResponse {
8075 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8078 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8079 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8080 };
8081
8082 let (maybe_a, maybe_b) = {
8083 let reg = state.registry.lock().await;
8084 (
8085 reg.find_by_run_id(&run_id_a).cloned(),
8086 reg.find_by_run_id(&run_id_b).cloned(),
8087 )
8088 };
8089
8090 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8091 let html = ErrorTemplate {
8092 message: "One or both run IDs were not found in scan history. \
8093 The runs may have been deleted or the registry may have been reset."
8094 .to_string(),
8095 last_report_url: Some("/compare-scans".to_string()),
8096 last_report_label: Some("Compare Scans".to_string()),
8097 run_id: None,
8098 error_code: None,
8099 csp_nonce: csp_nonce.clone(),
8100 version: env!("CARGO_PKG_VERSION"),
8101 }
8102 .render()
8103 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8104 return Html(html).into_response();
8105 };
8106
8107 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8109 (entry_a, entry_b)
8110 } else {
8111 (entry_b, entry_a)
8112 };
8113
8114 if baseline_entry.run_id != run_id_a {
8118 let canonical = format!(
8119 "/compare?a={}&b={}",
8120 baseline_entry.run_id, current_entry.run_id
8121 );
8122 return axum::response::Redirect::to(&canonical).into_response();
8123 }
8124
8125 let (Some(base_json), Some(curr_json)) = (
8126 baseline_entry.json_path.as_ref(),
8127 current_entry.json_path.as_ref(),
8128 ) else {
8129 let html = ErrorTemplate {
8130 message: "Full comparison requires JSON scan data, which was not saved for one or \
8131 both of these runs. JSON is now always saved for new scans — re-run the \
8132 affected projects to enable comparisons."
8133 .to_string(),
8134 last_report_url: Some("/compare-scans".to_string()),
8135 last_report_label: Some("Compare Scans".to_string()),
8136 run_id: None,
8137 error_code: None,
8138 csp_nonce: csp_nonce.clone(),
8139 version: env!("CARGO_PKG_VERSION"),
8140 }
8141 .render()
8142 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8143 return Html(html).into_response();
8144 };
8145
8146 let compare_url = format!(
8147 "/compare?a={}&b={}",
8148 baseline_entry.run_id, current_entry.run_id
8149 );
8150
8151 let baseline_run = match load_scan_for_compare(
8152 base_json,
8153 "baseline",
8154 &baseline_entry.run_id,
8155 state.server_mode,
8156 &compare_url,
8157 &csp_nonce,
8158 ) {
8159 Ok(r) => r,
8160 Err(resp) => return resp,
8161 };
8162 let current_run = match load_scan_for_compare(
8163 curr_json,
8164 "current",
8165 ¤t_entry.run_id,
8166 state.server_mode,
8167 &compare_url,
8168 &csp_nonce,
8169 ) {
8170 Ok(r) => r,
8171 Err(resp) => return resp,
8172 };
8173
8174 let active_submodule = query.sub.clone();
8175 let super_scope_active = query.scope.as_deref() == Some("super");
8176
8177 let submodule_options = baseline_run
8178 .submodule_summaries
8179 .iter()
8180 .chain(current_run.submodule_summaries.iter())
8181 .map(|s| s.name.clone())
8182 .collect::<std::collections::BTreeSet<_>>()
8183 .into_iter()
8184 .collect::<Vec<_>>();
8185 let has_any_submodule_data = !submodule_options.is_empty();
8186
8187 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8189 baseline_run,
8190 current_run,
8191 &active_submodule,
8192 super_scope_active,
8193 );
8194
8195 let comparison = compute_delta(&effective_baseline, &effective_current);
8196
8197 let file_rows: Vec<CompareFileDeltaRow> = comparison
8198 .file_deltas
8199 .iter()
8200 .map(|d| CompareFileDeltaRow {
8201 relative_path: d.relative_path.clone(),
8202 language: d.language.clone().unwrap_or_else(|| "—".into()),
8203 status: match d.status {
8204 FileChangeStatus::Added => "added".into(),
8205 FileChangeStatus::Removed => "removed".into(),
8206 FileChangeStatus::Modified => "modified".into(),
8207 FileChangeStatus::Unchanged => "unchanged".into(),
8208 },
8209 baseline_code: d.baseline_code,
8210 current_code: d.current_code,
8211 baseline_code_display: if d.status == FileChangeStatus::Added {
8212 "—".into()
8213 } else {
8214 d.baseline_code.to_string()
8215 },
8216 current_code_display: if d.status == FileChangeStatus::Removed {
8217 "—".into()
8218 } else {
8219 d.current_code.to_string()
8220 },
8221 code_delta_str: fmt_delta(d.code_delta),
8222 code_delta_class: delta_class(d.code_delta).into(),
8223 comment_delta_str: fmt_delta(d.comment_delta),
8224 comment_delta_class: delta_class(d.comment_delta).into(),
8225 total_delta_str: fmt_delta(d.total_delta),
8226 total_delta_class: delta_class(d.total_delta).into(),
8227 })
8228 .collect();
8229
8230 let project_path = baseline_entry
8231 .input_roots
8232 .first()
8233 .map(|s| sanitize_path_str(s))
8234 .unwrap_or_default();
8235 let lines_added = sum_added_code_lines(&comparison);
8236 let lines_removed = sum_removed_code_lines(&comparison);
8237 let churn = compute_churn_stats(
8238 comparison.summary.baseline_code,
8239 comparison.summary.current_code,
8240 lines_added,
8241 lines_removed,
8242 );
8243 let s = &comparison.summary;
8244 let template = CompareTemplate {
8245 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8246 version: env!("CARGO_PKG_VERSION"),
8247 project_label: baseline_entry.project_label.clone(),
8248 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8249 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8250 baseline_run_id: baseline_entry.run_id.clone(),
8251 current_run_id: current_entry.run_id.clone(),
8252 baseline_run_id_short: baseline_entry
8253 .run_id
8254 .split('-')
8255 .next_back()
8256 .unwrap_or(&baseline_entry.run_id)
8257 .chars()
8258 .take(7)
8259 .collect(),
8260 current_run_id_short: current_entry
8261 .run_id
8262 .split('-')
8263 .next_back()
8264 .unwrap_or(¤t_entry.run_id)
8265 .chars()
8266 .take(7)
8267 .collect(),
8268 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8269 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8270 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8271 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8272 project_path: project_path.clone(),
8273 baseline_code: s.baseline_code,
8274 current_code: s.current_code,
8275 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8276 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8277 baseline_files: s.baseline_files,
8278 current_files: s.current_files,
8279 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8280 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8281 baseline_comments: s.baseline_comments,
8282 current_comments: s.current_comments,
8283 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8284 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8285 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8286 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8287 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8288 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8289 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8290 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8291 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8292 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8293 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8294 code_lines_added: lines_added,
8295 code_lines_removed: lines_removed,
8296 code_lines_modified: sum_modified_code_lines(&comparison),
8297 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8298 new_scope: churn.new_scope,
8299 churn_rate_str: churn.churn_rate_str,
8300 churn_rate_class: churn.churn_rate_class,
8301 scope_flag: churn.scope_flag,
8302 files_added: comparison.files_added,
8303 files_removed: comparison.files_removed,
8304 files_modified: comparison.files_modified,
8305 files_unchanged: comparison.files_unchanged,
8306 file_rows,
8307 baseline_git_author: baseline_entry.git_author.clone(),
8308 current_git_author: current_entry.git_author.clone(),
8309 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8310 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8311 baseline_git_tags: baseline_entry.git_tags.clone(),
8312 current_git_tags: current_entry.git_tags.clone(),
8313 baseline_git_commit_date: baseline_entry
8314 .git_commit_date
8315 .as_deref()
8316 .and_then(fmt_git_date),
8317 current_git_commit_date: current_entry
8318 .git_commit_date
8319 .as_deref()
8320 .and_then(fmt_git_date),
8321 project_name: project_path
8322 .rsplit(['/', '\\'])
8323 .find(|s| !s.is_empty())
8324 .unwrap_or(&project_path)
8325 .to_string(),
8326 submodule_options,
8327 has_any_submodule_data,
8328 active_submodule,
8329 super_scope_active,
8330 toast_assets: sloc_toast_assets(&csp_nonce),
8331 csp_nonce,
8332 coverage_delta_card: build_coverage_delta_card(s),
8333 baseline_test_count: effective_baseline.summary_totals.test_count,
8334 current_test_count: effective_current.summary_totals.test_count,
8335 baseline_coverage_pct: s.baseline_coverage_line_pct,
8336 current_coverage_pct: s.current_coverage_line_pct,
8337 };
8338
8339 Html(
8340 template
8341 .render()
8342 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8343 )
8344 .into_response()
8345}
8346
8347fn format_number(n: u64) -> String {
8355 let s = n.to_string();
8356 let mut out = String::with_capacity(s.len() + s.len() / 3);
8357 let len = s.len();
8358 for (i, c) in s.chars().enumerate() {
8359 if i > 0 && (len - i).is_multiple_of(3) {
8360 out.push(',');
8361 }
8362 out.push(c);
8363 }
8364 out
8365}
8366
8367const fn badge_char_width(c: char) -> f64 {
8368 match c {
8369 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8370 'm' | 'w' => 9.0,
8371 ' ' => 4.0,
8372 _ => 6.5,
8373 }
8374}
8375
8376#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8377fn badge_text_px(text: &str) -> u32 {
8378 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8379}
8380
8381fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8382 let lw = badge_text_px(label) + 20;
8383 let rw = badge_text_px(value) + 20;
8384 let total = lw + rw;
8385 let lx = lw / 2;
8386 let rx = lw + rw / 2;
8387 let le = escape_html(label);
8388 let ve = escape_html(value);
8389 let ce = escape_html(color);
8390 format!(
8391 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8392 <rect width="{total}" height="20" fill="#555"/>
8393 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8394 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8395 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8396 <text x="{lx}" y="13">{le}</text>
8397 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8398 <text x="{rx}" y="13">{ve}</text>
8399 </g>
8400</svg>"##
8401 )
8402}
8403
8404#[derive(Deserialize)]
8405struct BadgeQuery {
8406 label: Option<String>,
8407 color: Option<String>,
8408}
8409
8410async fn badge_handler(
8411 State(state): State<AppState>,
8412 AxumPath(metric): AxumPath<String>,
8413 Query(query): Query<BadgeQuery>,
8414) -> Response {
8415 let entry = {
8416 let reg = state.registry.lock().await;
8417 reg.entries.first().cloned()
8418 };
8419
8420 let Some(entry) = entry else {
8421 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8422 return (
8423 [
8424 (header::CONTENT_TYPE, "image/svg+xml"),
8425 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8426 ],
8427 svg,
8428 )
8429 .into_response();
8430 };
8431
8432 let (default_label, value, default_color) = match metric.as_str() {
8433 "code-lines" => (
8434 "code lines",
8435 format_number(entry.summary.code_lines),
8436 "#4a78ee",
8437 ),
8438 "files" => (
8439 "files analyzed",
8440 format_number(entry.summary.files_analyzed),
8441 "#4a9862",
8442 ),
8443 "comment-lines" => (
8444 "comment lines",
8445 format_number(entry.summary.comment_lines),
8446 "#b35428",
8447 ),
8448 "blank-lines" => (
8449 "blank lines",
8450 format_number(entry.summary.blank_lines),
8451 "#7a5db0",
8452 ),
8453 _ => return StatusCode::NOT_FOUND.into_response(),
8454 };
8455
8456 let label = query.label.as_deref().unwrap_or(default_label);
8457 let color = query.color.as_deref().unwrap_or(default_color);
8458 let svg = render_badge_svg(label, &value, color);
8459
8460 (
8461 [
8462 (header::CONTENT_TYPE, "image/svg+xml"),
8463 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8464 ],
8465 svg,
8466 )
8467 .into_response()
8468}
8469
8470#[derive(Serialize)]
8478struct ApiCoverageBlock {
8479 lines_found: u64,
8480 lines_hit: u64,
8481 line_pct: f64,
8482 functions_found: u64,
8483 functions_hit: u64,
8484 function_pct: f64,
8485 branches_found: u64,
8486 branches_hit: u64,
8487 branch_pct: f64,
8488}
8489
8490#[derive(Serialize)]
8491struct ApiMetricsResponse {
8492 run_id: String,
8493 timestamp: String,
8494 project: String,
8495 summary: ApiSummaryPayload,
8496 languages: Vec<ApiLanguageRow>,
8497 #[serde(skip_serializing_if = "Option::is_none")]
8498 coverage: Option<ApiCoverageBlock>,
8499}
8500
8501#[derive(Serialize)]
8502struct ApiSummaryPayload {
8503 files_analyzed: u64,
8504 files_skipped: u64,
8505 code_lines: u64,
8506 comment_lines: u64,
8507 blank_lines: u64,
8508 total_physical_lines: u64,
8509 functions: u64,
8510 classes: u64,
8511 variables: u64,
8512 imports: u64,
8513}
8514
8515#[derive(Serialize)]
8516struct ApiLanguageRow {
8517 name: String,
8518 files: u64,
8519 code_lines: u64,
8520 comment_lines: u64,
8521 blank_lines: u64,
8522 functions: u64,
8523 classes: u64,
8524 variables: u64,
8525 imports: u64,
8526}
8527
8528async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8529 let entry = {
8530 let reg = state.registry.lock().await;
8531 reg.entries.first().cloned()
8532 };
8533 entry.map_or_else(
8534 || error::not_found("no scans recorded yet"),
8535 |e| build_metrics_response(&e),
8536 )
8537}
8538
8539async fn api_metrics_run_handler(
8540 State(state): State<AppState>,
8541 AxumPath(run_id): AxumPath<String>,
8542) -> Response {
8543 let entry = {
8544 let reg = state.registry.lock().await;
8545 reg.find_by_run_id(&run_id).cloned()
8546 };
8547 entry.map_or_else(
8548 || error::not_found("run not found"),
8549 |e| build_metrics_response(&e),
8550 )
8551}
8552
8553fn build_metrics_response(entry: &RegistryEntry) -> Response {
8554 let languages: Vec<ApiLanguageRow> = entry
8555 .json_path
8556 .as_ref()
8557 .and_then(|p| read_json(p).ok())
8558 .map(|run| {
8559 run.totals_by_language
8560 .iter()
8561 .map(|l| ApiLanguageRow {
8562 name: l.language.display_name().to_string(),
8563 files: l.files,
8564 code_lines: l.code_lines,
8565 comment_lines: l.comment_lines,
8566 blank_lines: l.blank_lines,
8567 functions: l.functions,
8568 classes: l.classes,
8569 variables: l.variables,
8570 imports: l.imports,
8571 })
8572 .collect()
8573 })
8574 .unwrap_or_default();
8575
8576 let s = &entry.summary;
8577 let coverage = if s.coverage_lines_found > 0 {
8578 let pct = |hit: u64, found: u64| -> f64 {
8579 if found == 0 {
8580 0.0
8581 } else {
8582 #[allow(clippy::cast_precision_loss)]
8583 let v = (hit as f64 / found as f64) * 100.0;
8584 (v * 10.0).round() / 10.0
8585 }
8586 };
8587 Some(ApiCoverageBlock {
8588 lines_found: s.coverage_lines_found,
8589 lines_hit: s.coverage_lines_hit,
8590 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8591 functions_found: s.coverage_functions_found,
8592 functions_hit: s.coverage_functions_hit,
8593 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8594 branches_found: s.coverage_branches_found,
8595 branches_hit: s.coverage_branches_hit,
8596 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8597 })
8598 } else {
8599 None
8600 };
8601 Json(ApiMetricsResponse {
8602 run_id: entry.run_id.clone(),
8603 timestamp: entry.timestamp_utc.to_rfc3339(),
8604 project: entry.project_label.clone(),
8605 summary: ApiSummaryPayload {
8606 files_analyzed: s.files_analyzed,
8607 files_skipped: s.files_skipped,
8608 code_lines: s.code_lines,
8609 comment_lines: s.comment_lines,
8610 blank_lines: s.blank_lines,
8611 total_physical_lines: s.total_physical_lines,
8612 functions: s.functions,
8613 classes: s.classes,
8614 variables: s.variables,
8615 imports: s.imports,
8616 },
8617 languages,
8618 coverage,
8619 })
8620 .into_response()
8621}
8622
8623#[derive(Deserialize)]
8630struct ProjectHistoryQuery {
8631 path: Option<String>,
8632}
8633
8634#[derive(Serialize)]
8635struct ProjectHistoryResponse {
8636 scan_count: usize,
8637 last_scan_id: Option<String>,
8638 last_scan_timestamp: Option<String>,
8639 last_scan_code_lines: Option<u64>,
8640 last_git_branch: Option<String>,
8641 last_git_commit: Option<String>,
8642}
8643
8644fn entry_matches_project(
8647 entry: &RegistryEntry,
8648 root_str: &str,
8649 upload_root: &str,
8650 upload_name_suffix: Option<&str>,
8651) -> bool {
8652 if entry.input_roots.iter().any(|r| r == root_str) {
8653 return true;
8654 }
8655 if let Some(suffix) = upload_name_suffix {
8656 return entry
8657 .input_roots
8658 .iter()
8659 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8660 }
8661 false
8662}
8663
8664async fn project_history_handler(
8665 State(state): State<AppState>,
8666 Query(query): Query<ProjectHistoryQuery>,
8667) -> Response {
8668 let path = query.path.unwrap_or_default();
8669 let resolved = resolve_input_path(&path);
8670 let root_str = resolved.to_string_lossy().replace('\\', "/");
8671
8672 let upload_root = std::env::temp_dir()
8677 .join("oxide-sloc-uploads")
8678 .to_string_lossy()
8679 .replace('\\', "/");
8680 let upload_name_suffix: Option<String> =
8681 if state.server_mode && root_str.starts_with(&upload_root) {
8682 resolved
8683 .file_name()
8684 .and_then(|n| n.to_str())
8685 .map(|name| format!("/{name}"))
8686 } else {
8687 None
8688 };
8689 let suffix_ref = upload_name_suffix.as_deref();
8690
8691 let entries: Vec<_> = {
8692 let reg = state.registry.lock().await;
8693 reg.entries
8694 .iter()
8695 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8696 .cloned()
8697 .collect()
8698 };
8699 let scan_count = entries.len();
8700 let last = entries.first();
8701 let last_scan_id = last.map(|e| e.run_id.clone());
8702 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8703 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8704 let last_git_branch = last.and_then(|e| e.git_branch.clone());
8705 let last_git_commit = last.and_then(|e| e.git_commit.clone());
8706
8707 Json(ProjectHistoryResponse {
8708 scan_count,
8709 last_scan_id,
8710 last_scan_timestamp,
8711 last_scan_code_lines,
8712 last_git_branch,
8713 last_git_commit,
8714 })
8715 .into_response()
8716}
8717
8718#[derive(Deserialize)]
8725struct MetricsHistoryQuery {
8726 root: Option<String>,
8727 limit: Option<usize>,
8728 submodule: Option<String>,
8731}
8732
8733#[derive(Serialize)]
8734struct MetricsSubmoduleLink {
8735 name: String,
8736 url: String,
8737}
8738
8739#[derive(Serialize)]
8740struct MetricsHistoryEntry {
8741 run_id: String,
8742 run_id_short: String,
8743 timestamp: String,
8744 commit: Option<String>,
8745 branch: Option<String>,
8746 tags: Vec<String>,
8747 nearest_tag: Option<String>,
8748 code_lines: u64,
8749 comment_lines: u64,
8750 blank_lines: u64,
8751 physical_lines: u64,
8752 files_analyzed: u64,
8753 files_skipped: u64,
8754 test_count: u64,
8755 project_label: String,
8756 html_url: Option<String>,
8757 has_pdf: bool,
8758 submodule_links: Vec<MetricsSubmoduleLink>,
8759 #[serde(skip_serializing_if = "Option::is_none")]
8761 coverage_line_pct: Option<f64>,
8762}
8763
8764fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8765 let mut links: Vec<MetricsSubmoduleLink> = vec![];
8766 let sub_dir = e
8767 .html_path
8768 .as_ref()
8769 .and_then(|p| p.parent())
8770 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8771 let Some(dir) = sub_dir else { return links };
8772 let Ok(rd) = std::fs::read_dir(dir) else {
8773 return links;
8774 };
8775 for entry_res in rd.flatten() {
8776 let fname = entry_res.file_name();
8777 let fname_str = fname.to_string_lossy();
8778 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8779 let stem = &fname_str[..fname_str.len() - 5];
8780 let display = stem[4..].replace('-', " ");
8781 links.push(MetricsSubmoduleLink {
8782 name: display,
8783 url: format!("/runs/{stem}/{}", e.run_id),
8784 });
8785 }
8786 }
8787 links.sort_by(|a, b| a.name.cmp(&b.name));
8788 links
8789}
8790
8791fn apply_submodule_filter(
8792 base: MetricsHistoryEntry,
8793 filter: &str,
8794 e: &sloc_core::history::RegistryEntry,
8795) -> Option<MetricsHistoryEntry> {
8796 let json_path = e.json_path.as_ref()?;
8797 let json_str = std::fs::read_to_string(json_path).ok()?;
8798 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8799 let sub = run
8800 .submodule_summaries
8801 .iter()
8802 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8803 let safe = sanitize_project_label(&sub.name);
8804 let artifact_key = format!("sub_{safe}");
8805 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8806 || base.html_url.clone(),
8807 |run_dir| {
8808 let sub_path = run_dir.join(format!("{artifact_key}.html"));
8809 if sub_path.exists() {
8810 Some(format!("/runs/{artifact_key}/{}", e.run_id))
8811 } else {
8812 base.html_url.clone()
8813 }
8814 },
8815 );
8816
8817 let sub_files: Vec<_> = run
8820 .per_file_records
8821 .iter()
8822 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8823 .collect();
8824 let test_count: u64 = sub_files
8825 .iter()
8826 .map(|r| r.raw_line_categories.test_count)
8827 .sum();
8828 #[allow(clippy::cast_precision_loss)]
8829 let coverage_line_pct: Option<f64> = {
8830 let found: u64 = sub_files
8831 .iter()
8832 .filter_map(|r| r.coverage.as_ref())
8833 .map(|c| u64::from(c.lines_found))
8834 .sum();
8835 let hit: u64 = sub_files
8836 .iter()
8837 .filter_map(|r| r.coverage.as_ref())
8838 .map(|c| u64::from(c.lines_hit))
8839 .sum();
8840 if found > 0 {
8841 let pct = (hit as f64 / found as f64) * 100.0;
8842 Some((pct * 10.0).round() / 10.0)
8843 } else {
8844 None
8845 }
8846 };
8847
8848 Some(MetricsHistoryEntry {
8849 code_lines: sub.code_lines,
8850 comment_lines: sub.comment_lines,
8851 blank_lines: sub.blank_lines,
8852 physical_lines: sub.total_physical_lines,
8853 files_analyzed: sub.files_analyzed,
8854 files_skipped: 0,
8855 test_count,
8856 html_url: sub_html_url,
8857 has_pdf: false,
8858 submodule_links: vec![],
8859 coverage_line_pct,
8860 ..base
8861 })
8862}
8863
8864#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
8866 State(state): State<AppState>,
8867 Query(query): Query<MetricsHistoryQuery>,
8868) -> Response {
8869 let limit = query.limit.unwrap_or(50).min(500);
8870 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8871
8872 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8873 let reg = state.registry.lock().await;
8874 reg.entries
8875 .iter()
8876 .filter(|e| {
8877 query.root.as_ref().is_none_or(|root| {
8878 let resolved = resolve_input_path(root);
8879 let root_str = resolved.to_string_lossy().replace('\\', "/");
8880 e.input_roots.iter().any(|r| r == &root_str)
8881 })
8882 })
8883 .take(limit)
8884 .cloned()
8885 .collect()
8886 };
8887
8888 let entries: Vec<MetricsHistoryEntry> = candidate_entries
8889 .into_iter()
8890 .filter_map(|e| {
8891 let tags = e
8892 .git_tags
8893 .as_deref()
8894 .map(|s| {
8895 s.split(',')
8896 .map(|t| t.trim().to_string())
8897 .filter(|t| !t.is_empty())
8898 .collect()
8899 })
8900 .unwrap_or_default();
8901 let html_url = e
8902 .html_path
8903 .as_ref()
8904 .filter(|p| p.exists())
8905 .map(|_| format!("/runs/html/{}", e.run_id));
8906 let nearest_tag = e.git_nearest_tag.clone();
8907 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8908 let run_id_short: String = e
8909 .run_id
8910 .split('-')
8911 .next_back()
8912 .unwrap_or(&e.run_id)
8913 .chars()
8914 .take(7)
8915 .collect();
8916 let submodule_links = build_entry_submodule_links(&e);
8917 #[allow(clippy::cast_precision_loss)]
8918 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8919 let pct = (e.summary.coverage_lines_hit as f64
8920 / e.summary.coverage_lines_found as f64)
8921 * 100.0;
8922 Some((pct * 10.0).round() / 10.0)
8923 } else {
8924 None
8925 };
8926 let base = MetricsHistoryEntry {
8927 run_id: e.run_id.clone(),
8928 run_id_short,
8929 timestamp: e.timestamp_utc.to_rfc3339(),
8930 commit: e.git_commit.clone(),
8931 branch: e.git_branch.clone(),
8932 tags,
8933 nearest_tag,
8934 code_lines: e.summary.code_lines,
8935 comment_lines: e.summary.comment_lines,
8936 blank_lines: e.summary.blank_lines,
8937 physical_lines: e.summary.total_physical_lines,
8938 files_analyzed: e.summary.files_analyzed,
8939 files_skipped: e.summary.files_skipped,
8940 test_count: e.summary.test_count,
8941 project_label: e.project_label.clone(),
8942 html_url,
8943 has_pdf,
8944 submodule_links,
8945 coverage_line_pct,
8946 };
8947 if let Some(ref filter) = submodule_filter {
8948 apply_submodule_filter(base, filter, &e)
8949 } else {
8950 Some(base)
8951 }
8952 })
8953 .collect();
8954
8955 Json(entries).into_response()
8956}
8957
8958#[derive(Serialize)]
8960struct ChurnEntry {
8961 run_id: String,
8962 added: i64,
8963 removed: i64,
8964 modified: i64,
8965 unmodified: i64,
8966}
8967
8968async fn api_metrics_churn_handler(
8973 State(state): State<AppState>,
8974 Query(query): Query<MetricsHistoryQuery>,
8975) -> Response {
8976 let limit = query.limit.unwrap_or(200).min(500);
8977 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8978 let reg = state.registry.lock().await;
8979 reg.entries
8980 .iter()
8981 .filter(|e| {
8982 query.root.as_ref().is_none_or(|root| {
8983 let resolved = resolve_input_path(root);
8984 let root_str = resolved.to_string_lossy().replace('\\', "/");
8985 e.input_roots.iter().any(|r| r == &root_str)
8986 })
8987 })
8988 .take(limit)
8989 .cloned()
8990 .collect()
8991 };
8992 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8993 std::collections::HashMap::new();
8994 for e in candidate_entries {
8995 by_project
8996 .entry(e.project_label.clone())
8997 .or_default()
8998 .push(e);
8999 }
9000 let mut out: Vec<ChurnEntry> = Vec::new();
9001 for (_proj, mut entries) in by_project {
9002 entries.sort_by_key(|e| e.timestamp_utc);
9003 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9004 for e in &entries {
9005 let curr = e
9006 .json_path
9007 .as_ref()
9008 .and_then(|path| sloc_core::read_json(path).ok());
9009 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9010 let cmp = sloc_core::compute_delta(prev, cur);
9011 out.push(ChurnEntry {
9012 run_id: e.run_id.clone(),
9013 added: sum_added_code_lines(&cmp),
9014 removed: sum_removed_code_lines(&cmp),
9015 modified: sum_modified_code_lines(&cmp),
9016 unmodified: sum_unmodified_code_lines(&cmp),
9017 });
9018 } else {
9019 out.push(ChurnEntry {
9020 run_id: e.run_id.clone(),
9021 added: 0,
9022 removed: 0,
9023 modified: 0,
9024 unmodified: 0,
9025 });
9026 }
9027 if curr.is_some() {
9028 prev_run = curr;
9029 }
9030 }
9031 }
9032 Json(out).into_response()
9033}
9034
9035#[derive(Deserialize)]
9039struct MetricsSubmodulesQuery {
9040 root: Option<String>,
9041}
9042
9043#[derive(Serialize)]
9044struct SubmoduleEntry {
9045 name: String,
9046 relative_path: String,
9047}
9048
9049async fn api_metrics_submodules_handler(
9050 State(state): State<AppState>,
9051 Query(query): Query<MetricsSubmodulesQuery>,
9052) -> Response {
9053 let json_paths: Vec<std::path::PathBuf> = {
9054 let reg = state.registry.lock().await;
9055 reg.entries
9056 .iter()
9057 .filter(|e| {
9058 query.root.as_ref().is_none_or(|root| {
9059 let resolved = resolve_input_path(root);
9060 let root_str = resolved.to_string_lossy().replace('\\', "/");
9061 e.input_roots.iter().any(|r| r == &root_str)
9062 })
9063 })
9064 .filter_map(|e| e.json_path.clone())
9065 .collect()
9066 };
9067
9068 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9069 let mut result: Vec<SubmoduleEntry> = Vec::new();
9070
9071 for path in &json_paths {
9072 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9073 continue;
9074 };
9075 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9076 continue;
9077 };
9078 for sub in &run.submodule_summaries {
9079 if seen.insert(sub.name.clone()) {
9080 result.push(SubmoduleEntry {
9081 name: sub.name.clone(),
9082 relative_path: sub.relative_path.clone(),
9083 });
9084 }
9085 }
9086 }
9087
9088 result.sort_by(|a, b| a.name.cmp(&b.name));
9089 Json(result).into_response()
9090}
9091
9092#[derive(Deserialize)]
9101struct IngestQuery {
9102 label: Option<String>,
9103}
9104
9105#[derive(Serialize)]
9106struct IngestResponse {
9107 run_id: String,
9108 view_url: String,
9109}
9110
9111async fn api_ingest_handler(
9112 State(state): State<AppState>,
9113 Query(q): Query<IngestQuery>,
9114 Json(run): Json<sloc_core::AnalysisRun>,
9115) -> Response {
9116 let label = q.label.unwrap_or_else(|| {
9117 run.input_roots
9118 .first()
9119 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9120 });
9121
9122 let label_for_task = label.clone();
9123 let result = tokio::task::spawn_blocking(move || {
9124 let html = render_html(&run)?;
9125 let run_id = run.tool.run_id.clone();
9126 let run_id_safe = run_id.len() <= 128
9127 && !run_id.is_empty()
9128 && run_id
9129 .chars()
9130 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9131 if !run_id_safe {
9132 anyhow::bail!(
9133 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9134 );
9135 }
9136 let project_label = sanitize_project_label(&label_for_task);
9137 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9138 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9139 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9140 _ => project_label,
9141 };
9142 let (artifacts, _pending_pdf) = persist_run_artifacts(
9143 &run,
9144 &html,
9145 &output_dir,
9146 &label_for_task,
9147 &file_stem,
9148 RunResultContext::default(),
9149 )?;
9150 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9151 })
9152 .await;
9153
9154 match result {
9155 Ok(Ok((run_id, artifacts, run))) => {
9156 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9157 (
9158 StatusCode::CREATED,
9159 Json(IngestResponse {
9160 view_url: format!("/view-reports?run_id={run_id}"),
9161 run_id,
9162 }),
9163 )
9164 .into_response()
9165 }
9166 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9167 Err(e) => error::internal(&format!("{e}")),
9168 }
9169}
9170
9171fn html_escape(s: &str) -> String {
9175 s.replace('&', "&")
9176 .replace('<', "<")
9177 .replace('>', ">")
9178 .replace('"', """)
9179}
9180
9181#[allow(clippy::cast_precision_loss)]
9182fn fmt_num(n: i64) -> String {
9183 let a = n.unsigned_abs();
9184 if a >= 1_000_000 {
9185 let v = n as f64 / 1_000_000.0;
9186 let s = format!("{v:.1}");
9187 format!("{}M", s.trim_end_matches(".0"))
9188 } else if a >= 10_000 {
9189 let v = n as f64 / 1_000.0;
9190 let s = format!("{v:.1}");
9191 format!("{}K", s.trim_end_matches(".0"))
9192 } else {
9193 let sign = if n < 0 { "-" } else { "" };
9194 if a < 1_000 {
9195 return format!("{sign}{a}");
9196 }
9197 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9198 }
9199}
9200
9201fn fmt_comma(n: i64) -> String {
9202 let sign = if n < 0 { "-" } else { "" };
9203 let a = n.unsigned_abs();
9204 if a < 1_000 {
9205 return format!("{sign}{a}");
9206 }
9207 let s = a.to_string();
9208 let bytes = s.as_bytes();
9209 let len = bytes.len();
9210 let mut out = String::with_capacity(len + len / 3);
9211 for (i, &b) in bytes.iter().enumerate() {
9212 if i > 0 && (len - i).is_multiple_of(3) {
9213 out.push(',');
9214 }
9215 out.push(b as char);
9216 }
9217 format!("{sign}{out}")
9218}
9219
9220fn group_thousands(s: &str) -> String {
9227 let (sign, rest) = match s.as_bytes().first() {
9228 Some(b'-') => ("-", &s[1..]),
9229 Some(b'+') => ("+", &s[1..]),
9230 _ => ("", s),
9231 };
9232 let (int_part, frac_part) = match rest.split_once('.') {
9233 Some((i, f)) => (i, Some(f)),
9234 None => (rest, None),
9235 };
9236 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9237 return s.to_string();
9238 }
9239 let bytes = int_part.as_bytes();
9240 let len = bytes.len();
9241 let mut grouped = String::with_capacity(len + len / 3);
9242 for (i, &b) in bytes.iter().enumerate() {
9243 if i > 0 && (len - i).is_multiple_of(3) {
9244 grouped.push(',');
9245 }
9246 grouped.push(b as char);
9247 }
9248 frac_part.map_or_else(
9249 || format!("{sign}{grouped}"),
9250 |f| format!("{sign}{grouped}.{f}"),
9251 )
9252}
9253
9254mod filters {
9256 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9259 use askama::{Result, Values};
9260
9261 #[askama::filter_fn]
9266 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9267 Ok(super::group_thousands(&value.to_string()))
9268 }
9269}
9270
9271#[derive(Deserialize, Default)]
9272struct MultiCompareQuery {
9273 runs: Option<String>,
9274 scope: Option<String>,
9276 sub: Option<String>,
9278}
9279
9280#[allow(clippy::too_many_lines)]
9281async fn multi_compare_handler(
9282 State(state): State<AppState>,
9283 Query(params): Query<MultiCompareQuery>,
9284 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9285) -> impl IntoResponse {
9286 let run_ids: Vec<String> = params
9287 .runs
9288 .as_deref()
9289 .unwrap_or("")
9290 .split(',')
9291 .map(|s| s.trim().to_string())
9292 .filter(|s| !s.is_empty())
9293 .collect();
9294
9295 if run_ids.len() < 2 {
9296 return Html(
9297 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9298 <a href=\"/compare-scans\">Go back</a></p>",
9299 )
9300 .into_response();
9301 }
9302 if run_ids.len() > 20 {
9303 return Html(
9304 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9305 at once. <a href=\"/compare-scans\">Go back</a></p>",
9306 )
9307 .into_response();
9308 }
9309
9310 let entries: Vec<Option<RegistryEntry>> = {
9312 let reg = state.registry.lock().await;
9313 run_ids
9314 .iter()
9315 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9316 .collect()
9317 };
9318
9319 for (i, entry) in entries.iter().enumerate() {
9320 if entry.is_none() {
9321 let html = format!(
9322 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9323 found. <a href=\"/compare-scans\">Go back</a></p>",
9324 run_ids[i]
9325 );
9326 return Html(html).into_response();
9327 }
9328 }
9329
9330 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9331
9332 for entry in &entries {
9333 if entry.json_path.is_none() {
9334 let html = format!(
9335 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9336 JSON data — re-run the analysis to enable comparison. \
9337 <a href=\"/compare-scans\">Go back</a></p>",
9338 entry.run_id
9339 );
9340 return Html(html).into_response();
9341 }
9342 }
9343
9344 entries.sort_by_key(|e| e.timestamp_utc);
9346
9347 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9349 for entry in &entries {
9350 let path = entry.json_path.as_ref().unwrap();
9351 match read_json(path) {
9352 Ok(r) => runs.push(r),
9353 Err(e) => {
9354 let html = format!(
9355 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9356 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9357 entry.run_id
9358 );
9359 return Html(html).into_response();
9360 }
9361 }
9362 }
9363
9364 let all_sub_names: Vec<String> = {
9366 let mut set = std::collections::BTreeSet::new();
9367 for r in &runs {
9368 for s in &r.submodule_summaries {
9369 set.insert(s.name.clone());
9370 }
9371 }
9372 set.into_iter().collect()
9373 };
9374 let has_submodule_data = !all_sub_names.is_empty();
9375 let active_submodule = params.sub.clone();
9376 let super_scope_active = params.scope.as_deref() == Some("super");
9377
9378 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9380
9381 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9382 let project_label = entries
9383 .first()
9384 .map_or("", |e| e.project_label.as_str())
9385 .to_string();
9386 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9387 let multi = compute_multi_delta(&run_refs);
9388 let html = multi_compare_page(
9389 &multi,
9390 &project_label,
9391 env!("CARGO_PKG_VERSION"),
9392 &csp_nonce,
9393 has_submodule_data,
9394 &all_sub_names,
9395 &runs_csv,
9396 super_scope_active,
9397 active_submodule.as_deref(),
9398 &entries,
9399 );
9400 (
9403 [(axum::http::header::CACHE_CONTROL, "no-store")],
9404 Html(html),
9405 )
9406 .into_response()
9407}
9408
9409const fn multi_delta_class(n: i64) -> &'static str {
9410 match n {
9411 1.. => "pos",
9412 ..=-1 => "neg",
9413 0 => "zero",
9414 }
9415}
9416
9417fn multi_fmt_delta(n: i64) -> String {
9418 if n > 0 {
9419 format!("+{n}")
9420 } else {
9421 format!("{n}")
9422 }
9423}
9424
9425fn js_escape(s: &str) -> String {
9427 use std::fmt::Write as _;
9428 let mut out = String::with_capacity(s.len() + 2);
9429 for c in s.chars() {
9430 match c {
9431 '"' => out.push_str("\\\""),
9432 '\\' => out.push_str("\\\\"),
9433 '\n' => out.push_str("\\n"),
9434 '\r' => out.push_str("\\r"),
9435 '\t' => out.push_str("\\t"),
9436 c if (c as u32) < 0x20 => {
9437 let _ = write!(out, "\\u{:04x}", c as u32);
9438 }
9439 c => out.push(c),
9440 }
9441 }
9442 out
9443}
9444
9445fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9447 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9448 return (
9449 "—".to_string(),
9450 "<span class=\"mc-row-val\">—</span>".to_string(),
9451 );
9452 };
9453 let cd = entry
9454 .git_commit_date
9455 .as_deref()
9456 .and_then(fmt_git_date)
9457 .unwrap_or_else(|| "—".to_string());
9458 let au = entry.git_author.as_deref().map_or_else(
9459 || "<span class=\"mc-row-val\">—</span>".to_string(),
9460 |a| {
9461 format!(
9462 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9463 <span class=\"cmp-author-handle\"></span></span>",
9464 html_escape(a)
9465 )
9466 },
9467 );
9468 (cd, au)
9469}
9470
9471fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9473 active_sub.map_or_else(
9474 || {
9475 if super_scope_active {
9476 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9477 } else {
9478 "<span class=\"mc-scope-tag mc-scope-full\">\
9479 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9480 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9481 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9482 <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>\
9483 </svg> Full scan</span>"
9484 .to_string()
9485 }
9486 },
9487 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9488 )
9489}
9490
9491fn build_mc_scan_strip(
9493 multi: &MultiScanComparison,
9494 entries: &[RegistryEntry],
9495 n: usize,
9496 is_many: bool,
9497 active_sub: Option<&str>,
9498 super_scope_active: bool,
9499 project_label: &str,
9500) -> String {
9501 use std::fmt::Write as _;
9502 let mut scan_strip = String::new();
9503 for (i, pt) in multi.points.iter().enumerate() {
9504 let ts_ms = pt.timestamp.timestamp_millis();
9505 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9506 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9507 let branch = pt.git_branch.as_deref().unwrap_or("");
9508 let report_link = format!("/runs/html/{}", pt.run_id);
9509 let branch_html = if branch.is_empty() {
9510 "<span class=\"mc-row-val\">—</span>".to_string()
9511 } else {
9512 format!(
9513 "<span class=\"mc-card-branch\">{}</span>",
9514 html_escape(branch)
9515 )
9516 };
9517 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9518 let tags_html = pt
9519 .git_tags
9520 .as_deref()
9521 .filter(|t| !t.is_empty())
9522 .map(|t| {
9523 let chips = t
9524 .split(',')
9525 .filter(|s| !s.is_empty())
9526 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9527 .collect::<Vec<_>>()
9528 .join(" ");
9529 format!(
9530 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9531 <span class=\"mc-row-val\">{chips}</span></div>"
9532 )
9533 })
9534 .unwrap_or_default();
9535 let nearest = pt
9536 .git_nearest_tag
9537 .as_deref()
9538 .map(|t| format!("near {}", html_escape(t)))
9539 .unwrap_or_default();
9540 let arrow = if i < n - 1 && !is_many {
9541 "<div class='mc-arrow'>→</div>"
9542 } else {
9543 ""
9544 };
9545 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9546 let nearest_html = if nearest.is_empty() {
9547 String::new()
9548 } else {
9549 format!(
9550 "<span class=\"mc-card-nearest-wrap\">\
9551 <span class=\"mc-card-nearest\">{nearest}</span>\
9552 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9553 </span>"
9554 )
9555 };
9556 write!(
9557 scan_strip,
9558 r#"<div class="mc-card">
9559 <div class="mc-card-header">
9560 <div class="mc-card-num">Scan {num}</div>
9561 <div class="mc-card-project-col">
9562 <div class="mc-card-project">{project_label}</div>
9563 {scope_badge}
9564 </div>
9565 </div>
9566 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9567 <div class="mc-card-rows">
9568 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9569 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9570 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9571 <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>
9572 {tags_html}
9573 </div>
9574 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9575 </div>{arrow}"#,
9576 num = i + 1,
9577 commit = html_escape(commit),
9578 commit_date = commit_date_html,
9579 ts_ms = ts_ms,
9580 code = fmt_num(pt.code_lines),
9581 scope_badge = scope_badge,
9582 nearest_html = nearest_html,
9583 )
9584 .unwrap();
9585 }
9586 scan_strip
9587}
9588
9589#[allow(clippy::too_many_lines)]
9591fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9592 use std::fmt::Write as _;
9593 struct MetricRow<'a> {
9594 label: &'a str,
9595 values: Vec<i64>,
9596 seq_deltas: Vec<i64>,
9597 net_delta: i64,
9598 }
9599 let rows: Vec<MetricRow<'_>> = vec![
9600 MetricRow {
9601 label: "Code Lines",
9602 values: multi.points.iter().map(|p| p.code_lines).collect(),
9603 seq_deltas: multi
9604 .sequential_deltas
9605 .iter()
9606 .map(|d| d.summary.code_lines_delta)
9607 .collect(),
9608 net_delta: multi.total_delta.code_lines_delta,
9609 },
9610 MetricRow {
9611 label: "Files Analyzed",
9612 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9613 seq_deltas: multi
9614 .sequential_deltas
9615 .iter()
9616 .map(|d| d.summary.files_analyzed_delta)
9617 .collect(),
9618 net_delta: multi.total_delta.files_analyzed_delta,
9619 },
9620 MetricRow {
9621 label: "Comment Lines",
9622 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9623 seq_deltas: multi
9624 .sequential_deltas
9625 .iter()
9626 .map(|d| d.summary.comment_lines_delta)
9627 .collect(),
9628 net_delta: multi.total_delta.comment_lines_delta,
9629 },
9630 MetricRow {
9631 label: "Blank Lines",
9632 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9633 seq_deltas: multi
9634 .sequential_deltas
9635 .iter()
9636 .map(|d| d.summary.blank_lines_delta)
9637 .collect(),
9638 net_delta: multi.total_delta.blank_lines_delta,
9639 },
9640 MetricRow {
9641 label: "Tests",
9642 values: multi.points.iter().map(|p| p.test_count).collect(),
9643 seq_deltas: multi
9644 .points
9645 .windows(2)
9646 .map(|pts| pts[1].test_count - pts[0].test_count)
9647 .collect(),
9648 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9649 - multi.points.first().map_or(0, |f| f.test_count),
9650 },
9651 ];
9652 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9653 for i in 0..n {
9654 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9655 if i < n - 1 {
9656 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9657 }
9658 }
9659 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9660 let mut metrics_tbody = String::new();
9661 for row in &rows {
9662 metrics_tbody.push_str("<tr>");
9663 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9664 for i in 0..n {
9665 write!(
9666 metrics_tbody,
9667 "<td class='mc-val-col'>{}</td>",
9668 fmt_comma(row.values[i])
9669 )
9670 .unwrap();
9671 if i < n - 1 {
9672 let d = row.seq_deltas[i];
9673 write!(
9674 metrics_tbody,
9675 "<td class='mc-delta-col {cls}'>{val}</td>",
9676 cls = multi_delta_class(d),
9677 val = multi_fmt_delta(d)
9678 )
9679 .unwrap();
9680 }
9681 }
9682 let nd = row.net_delta;
9683 write!(
9684 metrics_tbody,
9685 "<td class='mc-net-col {cls}'>{val}</td>",
9686 cls = multi_delta_class(nd),
9687 val = multi_fmt_delta(nd)
9688 )
9689 .unwrap();
9690 metrics_tbody.push_str("</tr>");
9691 }
9692 (metrics_thead, metrics_tbody)
9693}
9694
9695fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9697 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9698 for (i, pt) in multi.points.iter().enumerate() {
9699 let commit = pt.git_commit.as_deref().unwrap_or("");
9700 let branch = pt.git_branch.as_deref().unwrap_or("");
9701 let tags = pt.git_tags.as_deref().unwrap_or("");
9702 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9703 let scanned_ms = pt.timestamp.timestamp_millis();
9704 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9705 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9706 let commit_date = entry
9707 .and_then(|e| e.git_commit_date.as_deref())
9708 .and_then(fmt_git_date)
9709 .unwrap_or_default();
9710 let author = entry
9711 .and_then(|e| e.git_author.as_deref())
9712 .unwrap_or("")
9713 .to_string();
9714 let cov = pt
9715 .coverage_line_pct
9716 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9717 parts.push(format!(
9718 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}}}"#,
9719 run_id = js_escape(&pt.run_id),
9720 commit = js_escape(commit),
9721 branch = js_escape(branch),
9722 tags = js_escape(tags),
9723 nearest = js_escape(nearest),
9724 commit_date = js_escape(&commit_date),
9725 author = js_escape(&author),
9726 scanned = js_escape(&scanned),
9727 code = pt.code_lines,
9728 comments = pt.comment_lines,
9729 blank = pt.blank_lines,
9730 files = pt.files_analyzed,
9731 tests = pt.test_count,
9732 ));
9733 }
9734 format!("[{}]", parts.join(","))
9735}
9736
9737fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9739 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9740 for row in &multi.file_matrix {
9741 let lang = row.language.as_deref().unwrap_or("");
9742 let codes: Vec<String> = row
9743 .code_per_scan
9744 .iter()
9745 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9746 .collect();
9747 let deltas: Vec<String> = row
9748 .code_delta_per_scan
9749 .iter()
9750 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9751 .collect();
9752 parts.push(format!(
9753 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9754 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9755 status = row.overall_status,
9756 codes = codes.join(","),
9757 deltas = deltas.join(","),
9758 total = row.total_code_delta,
9759 ));
9760 }
9761 format!("[{}]", parts.join(","))
9762}
9763
9764fn build_mc_file_col_headers(n: usize) -> String {
9766 use std::fmt::Write as _;
9767 let mut out = String::new();
9768 for i in 0..n {
9769 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9770 if i < n - 1 {
9771 write!(
9772 out,
9773 "<th class='file-delta-col'>Δ→{}</th>",
9774 i + 2
9775 )
9776 .unwrap();
9777 }
9778 }
9779 out
9780}
9781
9782fn build_mc_scope_bar(
9784 has_submodule_data: bool,
9785 sub_names: &[String],
9786 runs_csv: &str,
9787 active_sub: Option<&str>,
9788 super_scope_active: bool,
9789) -> String {
9790 use std::fmt::Write as _;
9791 if !has_submodule_data {
9792 return String::new();
9793 }
9794 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9795 let full_active = active_sub.is_none() && !super_scope_active;
9796 let mut bar = format!(
9797 r#"<div class="submod-scope-bar">
9798 <span class="submod-scope-label">
9799 <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>
9800 Scope:
9801 </span>
9802 <div class="submod-scope-divider"></div>
9803 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9804 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
9805 full_cls = if full_active { " active" } else { "" },
9806 super_cls = if super_scope_active { " active" } else { "" },
9807 );
9808 for s in sub_names {
9809 let is_active = active_sub == Some(s.as_str());
9810 write!(
9811 bar,
9812 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9813 cls = if is_active { " active" } else { "" },
9814 name_enc = html_escape(s),
9815 name_esc = html_escape(s),
9816 )
9817 .unwrap();
9818 }
9819 bar.push_str("\n</div>");
9820 bar
9821}
9822
9823fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9825 active_sub.map_or_else(
9826 || {
9827 if super_scope_active {
9828 "Super-repo only — ".to_string()
9829 } else {
9830 String::new()
9831 }
9832 },
9833 |s| format!("Submodule: {} — ", html_escape(s)),
9834 )
9835}
9836
9837#[allow(clippy::too_many_lines)]
9838#[allow(clippy::too_many_arguments)]
9839fn multi_compare_page(
9840 multi: &MultiScanComparison,
9841 project_label: &str,
9842 version: &str,
9843 csp_nonce: &str,
9844 has_submodule_data: bool,
9845 sub_names: &[String],
9846 runs_csv: &str,
9847 super_scope_active: bool,
9848 active_sub: Option<&str>,
9849 entries: &[RegistryEntry],
9850) -> String {
9851 let n = multi.points.len();
9852 let is_many = n > 4;
9853 let mc_strip_class = if is_many {
9854 "mc-strip mc-strip-grid"
9855 } else {
9856 "mc-strip"
9857 };
9858
9859 let scan_strip = build_mc_scan_strip(
9861 multi,
9862 entries,
9863 n,
9864 is_many,
9865 active_sub,
9866 super_scope_active,
9867 project_label,
9868 );
9869
9870 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9872
9873 let points_json = build_mc_points_json(multi, entries);
9875 let file_matrix_json = build_mc_file_matrix_json(multi);
9876
9877 let files_modified = multi
9879 .file_matrix
9880 .iter()
9881 .filter(|f| f.overall_status == "modified")
9882 .count();
9883 let files_added = multi
9884 .file_matrix
9885 .iter()
9886 .filter(|f| f.overall_status == "added")
9887 .count();
9888 let files_removed = multi
9889 .file_matrix
9890 .iter()
9891 .filter(|f| f.overall_status == "removed")
9892 .count();
9893 let files_unchanged = multi
9894 .file_matrix
9895 .iter()
9896 .filter(|f| f.overall_status == "unchanged")
9897 .count();
9898 let total_files = multi.file_matrix.len();
9899
9900 let file_col_headers = build_mc_file_col_headers(n);
9901 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9902 let scope_bar_html = build_mc_scope_bar(
9903 has_submodule_data,
9904 sub_names,
9905 runs_csv,
9906 active_sub,
9907 super_scope_active,
9908 );
9909 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9910 let toast_assets = sloc_toast_assets(csp_nonce);
9911
9912 format!(
9913 r#"<!doctype html>
9914<html lang="en">
9915<head>
9916 <meta charset="utf-8">
9917 <meta name="viewport" content="width=device-width, initial-scale=1">
9918 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9919 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9920 <style nonce="{csp_nonce}">
9921 :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;}}
9922 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9923 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9924 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;}}
9925 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9926 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9927 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9928 .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;}}
9929 @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));}}}}
9930 .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);}}
9931 .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;}}
9932 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9933 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9934 @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;}}}}
9935 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9936 .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));}}
9937 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9938 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9939 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9940 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9941 .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;}}
9942 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9943 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9944 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9945 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9946 .nav-dropdown{{position:relative;display:inline-flex;}}
9947 .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;}}
9948 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9949 .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;}}
9950 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9951 .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);}}
9952 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9953 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9954 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9955 body:not(.dark-theme) .icon-sun{{display:none;}}
9956 body.dark-theme .icon-moon{{display:none;}}
9957 .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;}}
9958 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9959 .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);}}
9960 .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;}}
9961 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9962 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9963 .settings-modal-body{{padding:14px 16px 16px;}}
9964 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9965 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9966 .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;}}
9967 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9968 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9969 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9970 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9971 .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;}}
9972 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9973 .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;}}
9974 .btn-back:hover{{background:var(--line);}}
9975 .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;}}
9976 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;}}
9977 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9978 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9979 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9980 .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;}}
9981 .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;}}
9982 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9983 .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;}}
9984 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9985 body.dark-theme .mc-card{{background:var(--surface-2);}}
9986 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9987 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9988 .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%;}}
9989 .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;}}
9990 .mc-card-commit:hover{{color:var(--oxide);}}
9991 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9992 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9993 .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;}}
9994 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9995 .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;}}
9996 .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;}}
9997 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9998 .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;}}
9999 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10000 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10001 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10002 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10003 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10004 .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);}}
10005 .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);}}
10006 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10007 .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;}}
10008 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10009 .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;}}
10010 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10011 .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;}}
10012 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10013 .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;}}
10014 .submod-scope-btn:hover{{background:var(--line);}}
10015 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10016 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10017 .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;}}
10018 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10019 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10020 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10021 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10022 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10023 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10024 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10025 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10026 .metrics-table .pos{{color:var(--pos);}}
10027 .metrics-table .neg{{color:var(--neg);}}
10028 .metrics-table .zero{{color:var(--muted);}}
10029 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10030 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10031 .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;}}
10032 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10033 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10034 .chart-wrap{{width:100%;overflow-x:auto;}}
10035 #mc-chart{{display:block;width:100%;}}
10036 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10037 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10038 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10039 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10040 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10041 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10042 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10043 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10044 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10045 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10046 .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;}}
10047 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10048 .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;}}
10049 .ic-svg-modal-ov.open{{display:flex;}}
10050 .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);}}
10051 .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);}}
10052 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10053 .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;}}
10054 .ic-svg-modal-close:hover{{background:var(--line);}}
10055 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10056 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10057 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10058 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10059 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10060 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10061 #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;}}
10062 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10063 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10064 .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;}}
10065 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10066 .tab-btn:hover:not(.active){{background:var(--line);}}
10067 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10068 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10069 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10070 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10071 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10072 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10073 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10074 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10075 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10076 .table-wrap{{width:100%;overflow-x:auto;}}
10077 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10078 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10079 #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;}}
10080 #file-table th.left,#file-table td.left{{text-align:left;}}
10081 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10082 .file-delta-col{{color:var(--muted);font-size:11px;}}
10083 .file-net-col{{font-weight:800;}}
10084 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10085 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10086 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10087 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10088 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10089 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10090 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10091 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10092 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10093 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10094 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10095 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10096 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10097 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10098 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10099 tr.row-unchanged td{{color:var(--muted);}}
10100 tr.row-unchanged .status-badge{{opacity:.65;}}
10101 .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;}}
10102 .absent{{color:var(--muted);font-style:italic;}}
10103 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10104 .pagination-info{{font-size:12px;color:var(--muted);}}
10105 .pagination-btns{{display:flex;gap:5px;}}
10106 .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;}}
10107 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10108 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10109 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10110 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;}}
10111 .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;}}
10112 .export-btn:hover{{background:var(--line);}}
10113 .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;}}
10114 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10115 .site-footer a{{color:var(--muted);}}
10116 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;}}
10117 body.pdf-mode{{background:#fff!important;}}
10118 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10119 .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;}}
10120 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10121 .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;}}
10122 .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;}}
10123 .mc-modal-title{{font-size:18px;font-weight:800;}}
10124 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10125 .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;}}
10126 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10127 .mc-modal-body{{padding:18px 22px;}}
10128 .mc-modal-sec{{margin-bottom:20px;}}
10129 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10130 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10131 .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;}}
10132 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10133 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10134 .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;}}
10135 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10136 .mc-modal-row:last-child{{border-bottom:none;}}
10137 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10138 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10139 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10140 .mc-modal-val a:hover{{text-decoration:underline;}}
10141 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10142 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10143 .mc-modal-stat[data-tip]{{cursor:help;}}
10144 #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);}}
10145 .mc-card{{cursor:pointer;}}
10146 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10147 </style>
10148</head>
10149<body>
10150 {loading_overlay}
10151 <div class="background-watermarks" aria-hidden="true">
10152 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10153 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10154 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10155 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10156 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10157 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10158 </div>
10159 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10160 <div class="top-nav">
10161 <div class="top-nav-inner">
10162 <a class="brand" href="/">
10163 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10164 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10165 </a>
10166 <div class="nav-right">
10167 <a class="nav-pill" href="/">Home</a>
10168 <div class="nav-dropdown">
10169 <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>
10170 <div class="nav-dropdown-menu">
10171 <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>
10172 </div>
10173 </div>
10174 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10175 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10176 <div class="nav-dropdown">
10177 <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>
10178 <div class="nav-dropdown-menu">
10179 <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>
10180 </div>
10181 </div>
10182 <div class="server-status-wrap" id="server-status-wrap">
10183 <div class="nav-pill server-online-pill" id="server-status-pill">
10184 <span class="status-dot" id="status-dot"></span>
10185 <span id="server-status-label">Server</span>
10186 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10187 </div>
10188 <div class="server-status-tip">
10189 OxideSLOC is running — accessible on your network.
10190 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10191 </div>
10192 </div>
10193 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10194 <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>
10195 </button>
10196 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10197 <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>
10198 <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>
10199 </button>
10200 </div>
10201 </div>
10202 </div>
10203
10204 <div class="page">
10205 <!-- Hero header -->
10206 <div class="mc-hero">
10207 <div class="mc-hero-header">
10208 <div>
10209 <div class="mc-title">Multi-Scan Timeline</div>
10210 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10211 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10212 </div>
10213 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10214 <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>
10215 <div class="export-group" id="mc-top-export-group">
10216 <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>
10217 <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>
10218 </div>
10219 </div>
10220 </div>
10221 {scope_bar_html}
10222 <!-- Scan strip -->
10223 <div class="{mc_strip_class}">{scan_strip}</div>
10224 </div>
10225
10226 <!-- Summary metrics table -->
10227 <div class="panel">
10228 <div class="panel-title">Metric Progression</div>
10229 <div class="table-wrap">
10230 <table class="metrics-table">
10231 <thead>{metrics_thead}</thead>
10232 <tbody>{metrics_tbody}</tbody>
10233 </table>
10234 </div>
10235 </div>
10236
10237 <!-- Scan Charts -->
10238 <div class="panel" id="mc-charts-panel">
10239 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10240 <div class="ic-grid">
10241 <!-- Timeline line chart — spans full width -->
10242 <div class="ic-card" style="grid-column:span 2">
10243 <div class="ic-card-h2-row">
10244 <span class="ic-card-h2">Timeline</span>
10245 <div class="chart-toolbar" style="margin:0">
10246 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10247 <button class="chart-metric-btn" data-metric="files">Files</button>
10248 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10249 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10250 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10251 </div>
10252 </div>
10253 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10254 </div>
10255 <!-- Code Metrics: Scan 1 vs Latest -->
10256 <div class="ic-card">
10257 <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>
10258 <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>
10259 <div id="mc-ic-c1"></div>
10260 </div>
10261 <!-- Language Code Delta -->
10262 <div class="ic-card" id="mc-ic-lang-card">
10263 <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>
10264 <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>
10265 <div id="mc-ic-c3"></div>
10266 </div>
10267 <!-- Delta by Metric -->
10268 <div class="ic-card">
10269 <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>
10270 <div id="mc-ic-c2"></div>
10271 </div>
10272 <!-- File Change Distribution -->
10273 <div class="ic-card">
10274 <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>
10275 <div id="mc-ic-c4"></div>
10276 </div>
10277 </div>
10278 </div>
10279
10280 <!-- File matrix table -->
10281 <div class="panel">
10282 <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>
10283 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10284 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10285 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10286 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10287 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10288 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10289 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10290 </div>
10291 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10292 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10293 <div class="export-group">
10294 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10295 <button type="button" class="export-btn" id="export-csv-btn">
10296 <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>
10297 CSV
10298 </button>
10299 <button type="button" class="export-btn" id="mc-file-xls-btn">
10300 <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>
10301 Excel
10302 </button>
10303 </div>
10304 </div>
10305 </div>
10306 <div class="table-wrap">
10307 <table id="file-table">
10308 <thead>
10309 <tr>
10310 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10311 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10312 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10313 {file_col_headers}
10314 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10315 </tr>
10316 </thead>
10317 <tbody id="file-tbody"></tbody>
10318 </table>
10319 </div>
10320 <div class="pagination">
10321 <span class="pagination-info" id="pg-info"></span>
10322 <div class="pagination-btns" id="pg-btns"></div>
10323 <div style="display:flex;align-items:center;gap:6px;">
10324 <span style="font-size:12px;color:var(--muted)">Show</span>
10325 <select class="per-page" id="per-page-sel">
10326 <option value="25" selected>25 per page</option>
10327 <option value="50">50 per page</option>
10328 <option value="100">100 per page</option>
10329 </select>
10330 </div>
10331 </div>
10332 </div>
10333 </div>
10334
10335 <div id="mc-ic-tt"></div>
10336
10337 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10338 <div class="ic-svg-modal">
10339 <div class="ic-svg-modal-hdr">
10340 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10341 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10342 </div>
10343 <div id="ic-svg-modal-body"></div>
10344 </div>
10345 </div>
10346
10347 <footer class="site-footer">
10348 oxide-sloc v{version} — local code metrics workbench ·
10349 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10350 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10351 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10352 · <a href="/api-docs" rel="noopener">REST API</a>
10353 </footer>
10354
10355 <script nonce="{csp_nonce}">
10356 (function(){{
10357 // ── Dark theme ───────────────────────────────────────────────────────────
10358 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10359 var renderInlineCharts=null;
10360 var tt=document.getElementById('theme-toggle');
10361 if(tt)tt.addEventListener('click',function(){{
10362 var on=document.body.classList.toggle('dark-theme');
10363 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10364 renderChart(activeMetric);
10365 if(renderInlineCharts)renderInlineCharts();
10366 }});
10367
10368 // ── Code particles ───────────────────────────────────────────────────────
10369 var container=document.getElementById('code-particles');
10370 if(container){{
10371 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()'];
10372 for(var i=0;i<28;i++){{
10373 (function(idx){{
10374 var el=document.createElement('span');el.className='code-particle';
10375 el.textContent=snips[idx%snips.length];
10376 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10377 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10378 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10379 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10380 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10381 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10382 container.appendChild(el);
10383 }})(i);
10384 }}
10385 }}
10386
10387 // ── Watermarks ───────────────────────────────────────────────────────────
10388 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10389 if(wms.length){{
10390 var placed=[];
10391 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;}}
10392 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];}}
10393 var half=Math.floor(wms.length/2);
10394 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;}});
10395 }}
10396
10397 // ── Settings / colour scheme modal ───────────────────────────────────────
10398 (function(){{
10399 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'}}];
10400 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);}});}}
10401 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10402 function init(){{
10403 var btn=document.getElementById('settings-btn');if(!btn)return;
10404 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10405 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>';
10406 document.body.appendChild(m);
10407 var g=document.getElementById('scheme-grid');
10408 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);}});
10409 var cl=document.getElementById('settings-close-btn');
10410 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');}});
10411 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10412 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10413 }}
10414 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10415 }})();
10416
10417 // ── Timezone support for scan timestamps ─────────────────────────────────
10418 (function(){{
10419 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);}};
10420 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'';}}}};
10421 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);}});}};
10422 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10423 window.applyTz(storedTz);
10424 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);}});}}}}
10425 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10426 }})();
10427
10428 // ── Data ────────────────────────────────────────────────────────────────
10429 var POINTS={points_json};
10430 var FILES={file_matrix_json};
10431 var N={n};
10432
10433 // ── fmt helper ───────────────────────────────────────────────────────────
10434 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();}}
10435 function fmtFull(n){{return Number(n).toLocaleString();}}
10436 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10437
10438 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10439 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10440 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));}}
10441 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10442 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10443
10444 // ── Timeline chart ───────────────────────────────────────────────────────
10445 var activeMetric='code';
10446 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10447 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10448
10449 function renderChart(metric){{
10450 var svg=document.getElementById('mc-chart');if(!svg)return;
10451 var W=svg.getBoundingClientRect().width||800,H=280;
10452 svg.setAttribute('height',H);
10453 var pad={{l:62,r:20,t:32,b:72}};
10454 var dark=document.body.classList.contains('dark-theme');
10455 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10456 var valid=pts.filter(function(v){{return v!=null;}});
10457 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;}}
10458 var minV=0,maxV=Math.max.apply(null,valid);
10459 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10460 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10461 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10462 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10463 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10464 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10465 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10466 var parts=[];
10467 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10468 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>');}}
10469 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10470 var lineD='';var firstPt=true;
10471 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);}}}}
10472 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10473 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10474 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10475 for(var i=0;i<N;i++){{
10476 if(pts[i]==null)continue;
10477 var cx=xOf(i),cy=yOf(pts[i]);
10478 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10479 var hasTag=p.tags&&p.tags.length>0;
10480 // Permanent Y-value label above the dot
10481 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>');
10482 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+'"/>');
10483 var xanchor=i===0?'start':i===N-1?'end':'middle';
10484 // X-axis label at 2× the original size (18 px)
10485 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>');
10486 }}
10487 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10488 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10489 svg.innerHTML=parts.join('');
10490 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');}});
10491 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10492 svg.onmousemove=function(e){{
10493 var rect=svg.getBoundingClientRect();
10494 var scaleX=W/rect.width;
10495 var mouseX=(e.clientX-rect.left)*scaleX;
10496 var nearest=-1,minDist=Infinity;
10497 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;}}}}
10498 if(nearest<0)return;
10499 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10500 var xhair=svg.querySelector('.mc-xhair');
10501 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10502 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"/>';
10503 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10504 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10505 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>';
10506 var bx=rect.left+(nc/W*rect.width)+18;
10507 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10508 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10509 }};
10510 svg.onmouseleave=function(){{
10511 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10512 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10513 }};
10514 }}
10515
10516 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10517
10518 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10519 btn.addEventListener('click',function(){{
10520 activeMetric=this.dataset.metric;
10521 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10522 this.classList.add('active');
10523 renderChart(activeMetric);
10524 }});
10525 }});
10526 if(typeof ResizeObserver!=='undefined'){{
10527 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10528 }}
10529 renderChart(activeMetric);
10530
10531 // ── File matrix table ────────────────────────────────────────────────────
10532 var activeStatus='';
10533 var currentPage=1;
10534 var perPage=25;
10535 var mcSortCol=null,mcSortAsc=true;
10536
10537 function getFiltered(){{
10538 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10539 if(!mcSortCol)return data;
10540 var asc=mcSortAsc;
10541 return data.slice().sort(function(a,b){{
10542 var va,vb;
10543 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10544 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10545 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10546 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10547 else{{return 0;}}
10548 if(asc)return va<vb?-1:va>vb?1:0;
10549 return va<vb?1:va>vb?-1:0;
10550 }});
10551 }}
10552
10553 function renderFilePage(){{
10554 var filtered=getFiltered();
10555 var total=filtered.length;
10556 var totalPages=Math.max(1,Math.ceil(total/perPage));
10557 if(currentPage>totalPages)currentPage=totalPages;
10558 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10559 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10560 var rows=[];
10561 for(var i=start;i<end;i++){{
10562 var f=filtered[i];
10563 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10564 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10565 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10566 for(var j=0;j<N;j++){{
10567 var cv=f.c[j];
10568 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10569 if(j<N-1){{
10570 var dv=f.d[j+1];
10571 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10572 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10573 }}
10574 }}
10575 var tc=f.t;
10576 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10577 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10578 }}
10579 tbody.innerHTML=rows.join('');
10580
10581 var info=document.getElementById('pg-info');
10582 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10583 renderPgBtns(totalPages);
10584 }}
10585
10586 function renderPgBtns(totalPages){{
10587 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10588 var btns=[];
10589 function mkBtn(label,page,active,disabled){{
10590 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10591 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10592 }}
10593 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10594 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10595 if(s>1)btns.push(mkBtn('1',1,false,false));
10596 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10597 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10598 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10599 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10600 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10601 wrap.innerHTML=btns.join('');
10602 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10603 b.addEventListener('click',function(){{
10604 var pg=parseInt(this.dataset.pg,10);
10605 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10606 }});
10607 }});
10608 }}
10609
10610 // Tab filter
10611 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10612 btn.addEventListener('click',function(){{
10613 activeStatus=this.dataset.status||'';
10614 currentPage=1;
10615 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10616 this.classList.add('active');
10617 renderFilePage();
10618 }});
10619 }});
10620
10621 // Per-page selector
10622 var ppSel=document.getElementById('per-page-sel');
10623 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10624
10625 // ── Column header sort ───────────────────────────────────────────────────
10626 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10627 th.addEventListener('click',function(){{
10628 var col=th.dataset.sortCol;
10629 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10630 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10631 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10632 }});
10633 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10634 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10635 currentPage=1;renderFilePage();
10636 }});
10637 }});
10638
10639 // Reset button also clears sort
10640 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10641 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10642 mcSortCol=null;mcSortAsc=true;
10643 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10644 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10645 }});
10646 activeStatus='';currentPage=1;
10647 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10648 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10649 renderFilePage();
10650 }});
10651
10652 renderFilePage();
10653
10654 // ── CSV export ───────────────────────────────────────────────────────────
10655 var exportBtn=document.getElementById('export-csv-btn');
10656 if(exportBtn)exportBtn.addEventListener('click',function(){{
10657 var header=['File','Language','Status'];
10658 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10659 header.push('Net Delta');
10660 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10661 var filtered=getFiltered();
10662 filtered.forEach(function(f){{
10663 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10664 for(var j=0;j<N;j++){{
10665 cols.push(f.c[j]!=null?f.c[j]:'');
10666 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10667 }}
10668 cols.push(f.t);
10669 rows.push(cols.join(','));
10670 }});
10671 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10672 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10673 a.download=mcExportName('csv');a.click();
10674 }});
10675
10676 // ── File matrix extra export buttons ─────────────────────────────────────
10677 (function(){{
10678 var resetBtn=document.getElementById('mc-file-reset-btn');
10679 if(resetBtn)resetBtn.addEventListener('click',function(){{
10680 activeStatus='';currentPage=1;
10681 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10682 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10683 renderFilePage();
10684 }});
10685
10686 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10687 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10688 function mcMakeXlsx(fname){{
10689 var filtered=getFiltered();
10690 var enc=new TextEncoder();
10691 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;}}
10692 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;}}
10693 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10694 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10695 var ss=[],si={{}};
10696 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10697 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10698 function WS(){{
10699 var R=0,buf=[];
10700 function cl(c){{return String.fromCharCode(65+c);}}
10701 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10702 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10703 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10704 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>';}}
10705 return{{sc:sc,nc:nc,row:row,xml:xml}};
10706 }}
10707 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10708 var proj=mcExportProj();
10709 // \u2500\u2500 Summary sheet \u2500\u2500
10710 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10711 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10712 r1(s1(0,proj,2));
10713 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10714 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
10715 r1('');
10716 r1(s1(0,'SCAN SUMMARY',8));
10717 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));
10718 POINTS.forEach(function(p,i){{
10719 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10720 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));
10721 }});
10722 r1('');
10723 if(POINTS.length>1){{
10724 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10725 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10726 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10727 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)));}};
10728 nr('Code Lines',pf.code,pl.code);
10729 nr('Comment Lines',pf.comments,pl.comments);
10730 nr('Files Analyzed',pf.files,pl.files);
10731 nr('Tests',pf.tests,pl.tests);
10732 r1('');
10733 }}
10734 var cMod=0,cAdd=0,cRem=0,cUnch=0;
10735 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10736 var totF=FILES.length||1;
10737 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10738 r1(s1(0,'FILE CHANGES',8));
10739 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10740 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10741 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10742 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10743 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10744 var lm={{}};
10745 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;}});
10746 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10747 if(langs.length){{
10748 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10749 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10750 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)));}});
10751 }}
10752 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10753 // \u2500\u2500 File Delta sheet \u2500\u2500
10754 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10755 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10756 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);}}
10757 hcells+=s2(hc,'Net Delta',3);
10758 r2(hcells);
10759 filtered.forEach(function(f){{
10760 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10761 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));}}}}
10762 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10763 r2(cells);
10764 }});
10765 var ncols=3+N+(N-1)+1;
10766 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10767 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>';
10768 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10769 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>',
10770 '_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>',
10771 '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>',
10772 '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>',
10773 '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>',
10774 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10775 var zparts=[],zcds=[],zoff=0,znf=0;
10776 ['[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){{
10777 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10778 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]);
10779 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);
10780 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));
10781 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10782 zoff+=entry.length;znf++;
10783 }});
10784 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10785 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]);
10786 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10787 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10788 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10789 out.set(new Uint8Array(eocd),pos);
10790 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10791 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10792 }}
10793
10794 var xlsBtn=document.getElementById('mc-file-xls-btn');
10795 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10796
10797 // File matrix HTML export — interactive: sort by column, filter by status
10798 function mcFileBuildHtml(){{
10799 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10800 var hdrs=['File','Language','Status'];
10801 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10802 hdrs.push('Net \u0394');
10803 var SI=2;
10804 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;}});
10805 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10806 var cnt={{all:allRows.length}};
10807 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10808 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10809 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10810 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10811 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10812 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10813 '.sub{{font-size:12px;color:#99aabb;}}'+
10814 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10815 '.wr{{padding:16px 20px;}}'+
10816 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10817 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10818 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10819 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10820 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10821 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10822 'thead tr{{background:#1a2035;}}'+
10823 '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;}}'+
10824 'th:hover{{background:#2a3050;}}'+
10825 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10826 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10827 'tr:nth-child(even) td{{background:#faf7f4;}}'+
10828 'tr:hover td{{background:#f5f0ea;}}'+
10829 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10830 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10831 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10832 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10833 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10834 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10835 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10836 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10837 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10838 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
10839 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
10840 '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>";}}'+
10841 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10842 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10843 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
10844 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10845 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10846 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10847 '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("")'+
10848 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10849 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10850 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10851 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10852 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10853 'sd=(sc===ci)?-sd:1;sc=ci;'+
10854 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10855 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10856 'render();';
10857 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10858 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10859 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
10860 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10861 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10862 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10863 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10864 '<script>'+inlineJs+'<\/script><\/body><\/html>';
10865 }}
10866
10867 var htmlBtn=document.getElementById('mc-file-html-btn');
10868 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10869 var h=mcFileBuildHtml();
10870 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10871 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10872 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10873 }});
10874
10875 var pdfBtn=document.getElementById('mc-file-pdf-btn');
10876 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10877 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10878 }});
10879 }})();
10880
10881 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10882 (function(){{
10883 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10884 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10885 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10886 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10887 function fmt2(n){{return Number(n).toLocaleString();}}
10888 function px(n){{return Math.round(n);}}
10889 var _tt=document.getElementById('mc-ic-tt');
10890 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10891 function addTT(el){{
10892 if(!el)return;
10893 el.addEventListener('mouseover',function(e){{
10894 var t=e.target.closest('[data-ttl]');
10895 if(t&&_tt){{
10896 var ttl=t.getAttribute('data-ttl');
10897 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10898 _tt.style.display='block';mvTT(e);
10899 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10900 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10901 }} else {{
10902 if(_tt)_tt.style.display='none';
10903 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10904 }}
10905 }});
10906 el.addEventListener('mouseleave',function(){{
10907 if(_tt)_tt.style.display='none';
10908 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10909 }});
10910 el.addEventListener('mousemove',function(e){{mvTT(e);}});
10911 }}
10912 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';}}
10913 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10914 function buildCharts(){{
10915 if(N<2)return;
10916 var cs=getComputedStyle(document.body);
10917 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10918 var textCol=cv('--text','#43342d');
10919 var mutedCol=cv('--muted','#7b675b');
10920 var gFill=cv('--muted-2','#a08777');
10921 var LGY=cv('--line','#e6d0bf');
10922 var axisCol=cv('--line-strong','#d8bfad');
10923 var surf2col=cv('--surface-2','#f4ede4');
10924 var surfCol=cv('--surface','#fff8f0');
10925 var p0=POINTS[0],pLast=POINTS[N-1];
10926 var dark=document.body.classList.contains('dark-theme');
10927 var FADE=dark?'#524238':'#e6d0bf';
10928 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10929 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;}}
10930 var c1mets=[
10931 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10932 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10933 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10934 ];
10935 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10936 // Code Metrics chart — grows to fill the height its grid row settled to (the
10937 // Language Code Delta sibling usually drives that), so it never sits short at
10938 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10939 function drawC1(){{
10940 var C1W=620,C1H=200;
10941 var c1host=document.getElementById('mc-ic-c1');
10942 var c1card=c1host?c1host.closest('.ic-card'):null;
10943 if(c1host&&c1card&&c1host.clientWidth>0){{
10944 var avW=c1host.clientWidth;
10945 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10946 var wantH=availPx*C1W/avW;
10947 if(wantH>C1H)C1H=wantH;
10948 }}
10949 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10950 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10951 for(var gi=1;gi<=4;gi++){{
10952 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10953 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10954 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10955 }}
10956 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10957 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10958 c1mets.forEach(function(m,i){{
10959 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10960 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10961 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10962 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;"/>';
10963 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>';
10964 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;"/>';
10965 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>';
10966 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>';
10967 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>';
10968 }});
10969 c1+='</svg>';
10970 return c1;
10971 }}
10972 // Chart 2: Delta by Metric (net delta first scan to last)
10973 var mets=[
10974 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10975 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10976 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10977 ];
10978 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10979 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;
10980 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10981 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10982 mets.forEach(function(m,i){{
10983 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);
10984 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>';
10985 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;"/>';
10986 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>';}}
10987 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>';}}
10988 }});
10989 c2+='</svg>';
10990 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10991 var lm={{}};
10992 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;}});
10993 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10994 function drawC3(){{
10995 if(!langs.length)return'';
10996 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10997 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;
10998 var c3host=document.getElementById('mc-ic-c3');
10999 var c3card=document.getElementById('mc-ic-lang-card');
11000 var C3H=langs.length*30+24;
11001 if(c3host&&c3card&&c3host.clientWidth>0){{
11002 var avW=c3host.clientWidth;
11003 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11004 var wantH=availPx*C3W/avW;
11005 if(wantH>C3H)C3H=wantH;
11006 }}
11007 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11008 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11009 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11010 langs.forEach(function(l,i){{
11011 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);
11012 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11013 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"/>';
11014 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>';}}
11015 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>';}}
11016 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>';
11017 }});
11018 c3+='</svg>';
11019 return c3;
11020 }}
11021 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11022 var fm=0,fa=0,fr=0,fu=0;
11023 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11024 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;}});
11025 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11026 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11027 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11028 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;
11029 if(segs.length===1){{
11030 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"/>';
11031 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11032 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>';
11033 }} else {{
11034 segs.forEach(function(s){{
11035 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11036 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);
11037 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);
11038 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"/>';
11039 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>';}}
11040 ang4+=sw;
11041 }});
11042 }}
11043 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11044 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11045 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11046 segs.forEach(function(s,i){{
11047 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11048 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;"/>';
11049 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>';
11050 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11051 }});
11052 c4+='</svg>';
11053 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11054 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11055 // once at natural height to seed the row, then both are filled to the row the
11056 // grid settled to, so neither sits short at the top of an over-tall cell.
11057 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11058 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11059 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11060 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11061 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>';
11062 if(e1)e1.innerHTML=drawC1();
11063 }}
11064 buildCharts();
11065 renderInlineCharts=buildCharts;
11066 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11067 (function(){{
11068 var ov=document.getElementById('ic-svg-modal-ov');
11069 var body=document.getElementById('ic-svg-modal-body');
11070 var ttl=document.getElementById('ic-svg-modal-title');
11071 var closeBtn=document.getElementById('ic-svg-modal-close');
11072 if(!ov||!body)return;
11073 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11074 function open(srcId,title){{
11075 var src=document.getElementById(srcId);if(!src)return;
11076 ttl.textContent=title||'';
11077 var card=src.closest('.ic-card');
11078 var legHtml='';
11079 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11080 body.innerHTML=legHtml+src.innerHTML;
11081 var svg=body.querySelector('svg');
11082 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11083 addTT(body);
11084 ov.classList.add('open');
11085 }}
11086 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11087 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11088 }});
11089 if(closeBtn)closeBtn.addEventListener('click',close);
11090 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11091 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11092 }})();
11093
11094 // HTML legend hover → highlight matching SVG bars within the SAME card only
11095 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11096 var metric=leg.getAttribute('data-highlight');
11097 var parentCard=leg.closest('.ic-card');
11098 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11099 if(!chartEl)return;
11100 leg.addEventListener('mouseenter',function(){{
11101 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11102 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11103 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11104 x.style.opacity='1';
11105 }} else {{
11106 x.style.opacity='0.28';
11107 }}
11108 }});
11109 }});
11110 leg.addEventListener('mouseleave',function(){{
11111 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11112 }});
11113 }});
11114 // Author handles
11115 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11116
11117 // ── Export helpers ────────────────────────────────────────────────────────
11118 // Fetch one image from the server and return a data-URI Promise
11119 function mcFetchUri(path){{
11120 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11121 return new Promise(function(res){{
11122 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11123 }});
11124 }}).catch(function(){{return '';}});
11125 }}
11126 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11127 function mcInlineImgs(html,cb){{
11128 var paths=[],seen={{}};
11129 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11130 if(!paths.length){{cb(html);return;}}
11131 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11132 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11133 .catch(function(){{cb(html);}});
11134 }}
11135 // Capture full-page HTML with all table rows visible
11136 function mcRawHtml(pdfMode){{
11137 if(pdfMode)document.body.classList.add('pdf-mode');
11138 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11139 var html=document.documentElement.outerHTML;
11140 perPage=s;currentPage=p;renderFilePage();
11141 if(pdfMode)document.body.classList.remove('pdf-mode');
11142 return html;
11143 }}
11144
11145 // HTML export (full page with inlined images)
11146 function mcDoHtml(btn,fname){{
11147 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11148 mcInlineImgs(mcRawHtml(false),function(html){{
11149 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11150 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11151 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11152 btn.disabled=false;btn.innerHTML=orig;
11153 }});
11154 }}
11155 // PDF export — comprehensive document-style report: full numbers, all sections
11156 function mcBuildPdfHtml(){{
11157 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11158 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11159 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11160 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>';}}
11161 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11162 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11163 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)));}}
11164 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11165 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11166 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11167 // Header/footer flow in document order (NOT position:fixed) — a fixed
11168 // header repeats every printed page in Chromium and overlaps the content
11169 // below it, swallowing the first rows of pages 2+ and clipping the cards
11170 // on page 1. The table <thead> repeats per page natively, so every row
11171 // stays visible.
11172 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11173 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11174 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11175 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11176 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11177 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11178 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11179 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11180 '.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;}}'+
11181 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11182 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11183 '.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;}}'+
11184 '.body{{padding:12px 18px 0;}}'+
11185 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11186 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11187 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11188 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11189 '.sec{{margin-bottom:10px;}}'+
11190 '.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;}}'+
11191 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11192 '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;}}'+
11193 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11194 'tr:nth-child(even) td{{background:#faf8f6;}}';
11195 // ── Metric Progression ────────────────────────────────────────────────
11196 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11197 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11198 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>';
11199 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11200 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11201 var progRows=POINTS.map(function(pt,i){{
11202 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)));
11203 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11204 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11205 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11206 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11207 '<td style="text-align:right">'+full(pt.files)+'</td>';
11208 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11209 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11210 return r+'</tr>';
11211 }}).join('');
11212 // ── Scan-to-scan changes ──────────────────────────────────────────────
11213 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11214 var prev=POINTS[i];
11215 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11216 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11217 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11218 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11219 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11220 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11221 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11222 }}).join(''):'';
11223 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11224 var fmSection='';
11225 if(FILES&&FILES.length){{
11226 // Hard cap on per-scan columns so the table never overflows the page width.
11227 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11228 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11229 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11230 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11231 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11232 var fmRows=topFiles.map(function(f){{
11233 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11234 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>';
11235 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11236 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11237 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>';
11238 }}).join('');
11239 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11240 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11241 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11242 }}
11243 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11244 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11245 '<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>'+
11246
11247 '<div class="body">'+
11248 '<div class="sg">'+
11249 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11250 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11251 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11252 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11253 (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>':
11254 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11255 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11256 '</div>'+
11257 '<div class="sec"><p class="sh">Metric Progression</p>'+
11258 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11259 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11260 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11261 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11262 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11263 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11264 fmSection+
11265 '</div>'+
11266 '<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>'+
11267 '</body></html>';
11268 }}
11269 function mcDoPdf(btn){{
11270 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11271 }}
11272
11273 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11274 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11275 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11276 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11277 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11278 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11279 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11280 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11281 if(location.protocol==='file:'){{
11282 [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';}}}} );
11283 [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';}}}} );
11284 }}
11285 }})();
11286 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11287 (function(){{
11288 function $(id){{return document.getElementById(id);}}
11289 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11290 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11291 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11292 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11293 function openModal(idx){{
11294 var ov=$('mc-modal-overlay');if(!ov)return;
11295 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11296 if(idx<0||idx>=N)return;
11297 var pt=POINTS[idx];
11298 titleEl.textContent='Scan '+(idx+1);
11299 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11300 subEl.textContent=lbl;
11301 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11302 '<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>'+
11303 '<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>'+
11304 '<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>'+
11305 '<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>'+
11306 (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>':'')+
11307 (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>':'')+
11308 '</div></div>';
11309 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11310 (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>':'')+
11311 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11312 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11313 (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>':'')+
11314 (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>':'')+
11315 (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>':'')+
11316 (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>':'')+
11317 '<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>'+
11318 '</div>';
11319 var dHtml='';
11320 if(idx>0){{
11321 var prev=POINTS[idx-1];
11322 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11323 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11324 '<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>'+
11325 '<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>'+
11326 '<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>'+
11327 '</div></div>';
11328 }}
11329 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11330 ov.classList.add('open');document.body.style.overflow='hidden';
11331 }}
11332 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11333 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11334 document.addEventListener('click',function(e){{
11335 if(!e.target||!e.target.closest)return;
11336 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11337 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11338 var card=e.target.closest('.mc-card');
11339 if(!card)return;
11340 if(e.target.closest('a'))return;
11341 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11342 var i=cards.indexOf(card);
11343 if(i>=0)openModal(i);
11344 }});
11345 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11346 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11347 var statTip=null;
11348 document.addEventListener('mousemove',function(e){{
11349 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11350 if(!box){{if(statTip)statTip.style.display='none';return;}}
11351 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11352 var tip=box.getAttribute('data-tip')||'';
11353 if(statTip.textContent!==tip)statTip.textContent=tip;
11354 statTip.style.display='block';
11355 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11356 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11357 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11358 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11359 }});
11360 (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');}})();
11361 }})();
11362 }})();
11363 </script>
11364 <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]';
11365 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;}}
11366 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>
11367 <!-- Scan card detail modal -->
11368 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11369 <div class="mc-modal" id="mc-modal">
11370 <div class="mc-modal-head">
11371 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11372 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11373 </div>
11374 <div class="mc-modal-body" id="mc-modal-body"></div>
11375 </div>
11376 </div>
11377 {toast_assets}
11378</body>
11379</html>"#,
11380 project_label = html_escape(project_label),
11381 n = n,
11382 scan_strip = scan_strip,
11383 mc_strip_class = mc_strip_class,
11384 metrics_thead = metrics_thead,
11385 metrics_tbody = metrics_tbody,
11386 file_col_headers = file_col_headers,
11387 total_files = total_files,
11388 files_modified = files_modified,
11389 files_added = files_added,
11390 files_removed = files_removed,
11391 files_unchanged = files_unchanged,
11392 points_json = points_json,
11393 file_matrix_json = file_matrix_json,
11394 nav_compare_active = nav_compare_active,
11395 version = version,
11396 csp_nonce = csp_nonce,
11397 scope_bar_html = scope_bar_html,
11398 scope_label = scope_label,
11399 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11400 )
11401}
11402
11403#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11411 State(state): State<AppState>,
11412 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11413) -> Response {
11414 auto_scan_watched_dirs(&state).await;
11415
11416 let watched_dirs_list: Vec<String> = {
11417 let wd = state.watched_dirs.lock().await;
11418 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11419 };
11420
11421 let roots: Vec<String> = {
11423 let reg = state.registry.lock().await;
11424 let mut seen = std::collections::BTreeSet::new();
11425 reg.entries
11426 .iter()
11427 .flat_map(|e| e.input_roots.iter().cloned())
11428 .filter(|r| seen.insert(r.clone()))
11429 .collect()
11430 };
11431
11432 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11433 let nonce = &csp_nonce;
11434 let version = env!("CARGO_PKG_VERSION");
11435 let toast_assets = sloc_toast_assets(nonce);
11436
11437 let watched_dirs_html: String = if state.server_mode {
11441 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()
11442 } else {
11443 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11444 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11445 .to_string()
11446 } else {
11447 watched_dirs_list
11448 .iter()
11449 .fold(String::new(), |mut s, d| {
11450 use std::fmt::Write as _;
11451 let escaped =
11452 d.replace('&', "&").replace('"', """).replace('<', "<");
11453 write!(
11454 s,
11455 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>"#
11456 ).expect("write to String is infallible");
11457 s
11458 })
11459 };
11460 format!(
11461 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>"#
11462 )
11463 };
11464
11465 let html = format!(
11466 r##"<!doctype html>
11467<html lang="en">
11468<head>
11469 <meta charset="utf-8" />
11470 <meta name="viewport" content="width=device-width, initial-scale=1" />
11471 <title>OxideSLOC | Trend Reports</title>
11472 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11473 <style nonce="{nonce}">
11474 :root {{
11475 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11476 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11477 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11478 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11479 --info-bg:#eef3ff; --info-text:#4467d8;
11480 }}
11481 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11482 *{{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;}}
11483 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11484 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11485 .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;}}
11486 @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));}}}}
11487 .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);}}
11488 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11489 .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));}}
11490 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11491 .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;}}
11492 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11493 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11494 @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; }} }}
11495 .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;}}
11496 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11497 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11498 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11499 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11500 .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;}}
11501 .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;}}
11502 .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;}}
11503 .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;}}
11504 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11505 .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);}}
11506 .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;}}
11507 .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;}}
11508 .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;}}
11509 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11510 .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;}}
11511 .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);}}
11512 .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;}}
11513 .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;}}
11514 .tz-select:focus{{border-color:var(--oxide);}}
11515 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11516 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11517 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11518 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11519 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11520 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11521 .trend-title-block{{flex:1;min-width:0;}}
11522 .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;}}
11523 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11524 .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;}}
11525 .chart-select:focus{{border-color:var(--accent);}}
11526 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11527 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11528 .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);}}
11529 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11530 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11531 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11532 .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);}}
11533 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11534 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11535 .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;}}
11536 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11537 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11538 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11539 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11540 .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;}}
11541 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11542 .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;}}
11543 .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);}}
11544 .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;}}
11545 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11546 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11547 .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);}}
11548 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11549 .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;}}
11550 .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;}}
11551 .data-table tr:last-child td{{border-bottom:none;}}
11552 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11553 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11554 .table-wrap{{width:100%;overflow-x:auto;}}
11555 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11556 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11557 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11558 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11559 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11560 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11561 .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;}}
11562 .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;}}
11563 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11564 .pagination-info{{font-size:13px;color:var(--muted);}}
11565 .pagination-btns{{display:flex;gap:6px;}}
11566 .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;}}
11567 .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;}}
11568 #scan-history-table col:nth-child(1){{width:155px;}}
11569 #scan-history-table col:nth-child(2){{width:240px;}}
11570 #scan-history-table col:nth-child(3){{width:82px;}}
11571 #scan-history-table col:nth-child(4){{width:82px;}}
11572 #scan-history-table col:nth-child(5){{width:90px;}}
11573 #scan-history-table col:nth-child(6){{width:90px;}}
11574 #scan-history-table col:nth-child(7){{width:88px;}}
11575 #scan-history-table col:nth-child(8){{width:150px;}}
11576 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11577 .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;}}
11578 .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;}}
11579 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11580 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11581 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11582 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11583 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11584 .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;}}
11585 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11586 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11587 .watched-chip-rm:hover{{color:var(--oxide);}}
11588 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11589 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11590 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11591 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11592 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11593 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11594 a.run-link:hover{{text-decoration:underline;}}
11595 .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);}}
11596 .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);}}
11597 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11598 .metric-num{{font-weight:700;color:var(--text);}}
11599 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11600 .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;}}
11601 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11602 .btn.primary:hover{{opacity:.9;}}
11603 .rpt-btn{{min-width:58px;justify-content:center;}}
11604 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11605 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11606 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11607 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11608 .submod-details summary::-webkit-details-marker{{display:none;}}
11609 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11610 .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;}}
11611 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11612 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11613 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11614 .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;}}
11615 .export-btn:hover{{background:var(--line);}}
11616 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11617 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11618 .site-footer a{{color:var(--muted);}}
11619 .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;}}
11620 .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;}}
11621 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11622 /* Modal system (Retention Policy / Clean-up) */
11623 .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;}}
11624 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11625 .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);}}
11626 .tr-modal{{background:rgba(255,255,255,0.90);}}
11627 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11628 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11629 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11630 .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);}}
11631 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11632 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11633 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11634 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11635 .tr-modal-body{{padding:22px 30px;}}
11636 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11637 .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;}}
11638 .tr-btn:hover{{transform:translateY(-1px);}}
11639 .tr-btn:active{{transform:translateY(0);}}
11640 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11641 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11642 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11643 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11644 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11645 .tr-btn-secondary:hover{{background:var(--line);}}
11646 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11647 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11648 </style>
11649</head>
11650<body>
11651 <div class="background-watermarks" aria-hidden="true">
11652 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11653 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11654 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11655 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11656 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11657 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11658 </div>
11659 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11660 <div class="top-nav">
11661 <div class="top-nav-inner">
11662 <a class="brand" href="/">
11663 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11664 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11665 </a>
11666 <div class="nav-right">
11667 <a class="nav-pill" href="/">Home</a>
11668 <div class="nav-dropdown">
11669 <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>
11670 <div class="nav-dropdown-menu">
11671 <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>
11672 </div>
11673 </div>
11674 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11675 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11676 <div class="nav-dropdown">
11677 <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>
11678 <div class="nav-dropdown-menu">
11679 <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>
11680 </div>
11681 </div>
11682 <div class="server-status-wrap" id="server-status-wrap">
11683 <div class="nav-pill server-online-pill" id="server-status-pill">
11684 <span class="status-dot" id="status-dot"></span>
11685 <span id="server-status-label">Server</span>
11686 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11687 </div>
11688 <div class="server-status-tip">
11689 OxideSLOC is running — accessible on your network.
11690 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11691 </div>
11692 </div>
11693 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11694 <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>
11695 </button>
11696 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11697 <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>
11698 <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>
11699 </button>
11700 </div>
11701 </div>
11702 </div>
11703
11704 <div class="page">
11705 {watched_dirs_html}
11706 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
11707 <div class="scan-overlay-card">
11708 <div class="scan-spinner"></div>
11709 <div class="scan-overlay-text">Scanning folder…</div>
11710 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
11711 </div>
11712 </div>
11713 <style>
11714 .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);}}
11715 .scan-overlay.active{{display:flex;}}
11716 .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;}}
11717 .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;}}
11718 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
11719 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
11720 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
11721 </style>
11722 <div class="summary-strip" id="trend-stats"></div>
11723 <div class="panel">
11724 <div class="trend-header">
11725 <div class="trend-title-block">
11726 <h1>Trend Reports</h1>
11727 <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>
11728 <span class="chart-hint-inline">
11729 <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>
11730 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
11731 </span>
11732 </div>
11733 <div class="chart-actions">
11734 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11735 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11736 Retention Policy
11737 </button>
11738 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11739 <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>
11740 Clean up old runs
11741 </button>
11742 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11743 <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>
11744 Export Excel
11745 </button>
11746 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11747 <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>
11748 Export PNG
11749 </button>
11750 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11751 <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>
11752 Export PDF
11753 </button>
11754 </div>
11755 </div>
11756
11757 <div class="controls-centered">
11758 <label>Project Root:
11759 <select class="chart-select" id="root-sel">
11760 <option value="">All projects</option>
11761 </select>
11762 </label>
11763 <label>Y Metric:
11764 <select class="chart-select" id="y-sel">
11765 <option value="code_lines">Code Lines</option>
11766 <option value="comment_lines">Comment Lines</option>
11767 <option value="blank_lines">Blank Lines</option>
11768 <option value="physical_lines">Physical Lines</option>
11769 <option value="files_analyzed">Files Analyzed</option>
11770 </select>
11771 </label>
11772 <label>X Axis:
11773 <select class="chart-select" id="x-sel">
11774 <option value="time">By Time</option>
11775 <option value="commit" selected>By Commit</option>
11776 <option value="release">By Release</option>
11777 <option value="tag">Tagged Commits</option>
11778 </select>
11779 </label>
11780 <label id="submodule-label" style="display:none;">Submodule:
11781 <select class="chart-select" id="sub-sel">
11782 <option value="">All (project total)</option>
11783 </select>
11784 </label>
11785 <label>Chart Size:
11786 <select class="chart-select" id="scale-sel">
11787 <option value="0.75">Compact</option>
11788 <option value="1.2" selected>Normal</option>
11789 <option value="1.38">Large</option>
11790 </select>
11791 </label>
11792 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
11793 </div>
11794
11795 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
11796 <div id="data-table-wrap" style="overflow-x:auto;"></div>
11797 </div>
11798 </div>
11799
11800 <script nonce="{nonce}">
11801 (function() {{
11802 // Theme persistence
11803 var b = document.body;
11804 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11805 var tgl = document.getElementById('theme-toggle');
11806 if (tgl) tgl.addEventListener('click', function() {{
11807 var d = b.classList.toggle('dark-theme');
11808 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11809 }});
11810
11811 // Watermark randomizer
11812 (function() {{
11813 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11814 if (!wms.length) return;
11815 var placed = [];
11816 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;}}
11817 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];}}
11818 var half=Math.floor(wms.length/2);
11819 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;}});
11820 }})();
11821
11822 // Code particles
11823 (function() {{
11824 var container = document.getElementById('code-particles');
11825 if (!container) return;
11826 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'];
11827 for (var i = 0; i < 38; i++) {{
11828 (function(idx) {{
11829 var el = document.createElement('span');
11830 el.className = 'code-particle';
11831 el.textContent = snippets[idx % snippets.length];
11832 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11833 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11834 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11835 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';
11836 container.appendChild(el);
11837 }})(i);
11838 }}
11839 }})();
11840
11841 // Watched folder picker
11842 (function(){{
11843 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');}};
11844 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);
11845 }})();
11846 (function() {{
11847 var btn = document.getElementById('add-watched-btn');
11848 if (!btn) return;
11849 btn.addEventListener('click', function() {{
11850 fetch('/pick-directory?kind=reports')
11851 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11852 .then(function(data) {{
11853 if (!data.cancelled && data.selected_path) {{
11854 var form = document.createElement('form');
11855 form.method = 'POST';
11856 form.action = '/watched-dirs/add';
11857 var ri = document.createElement('input');
11858 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11859 var fi = document.createElement('input');
11860 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11861 form.appendChild(ri); form.appendChild(fi);
11862 document.body.appendChild(form);
11863 if (window.__scanOverlay) window.__scanOverlay();
11864 form.submit();
11865 }}
11866 }})
11867 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11868 }});
11869 }})();
11870
11871 // Settings / color-scheme modal
11872 (function() {{
11873 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'}}];
11874 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);}});}}
11875 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11876 var btn=document.getElementById('settings-btn');if(!btn)return;
11877 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11878 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>';
11879 document.body.appendChild(m);
11880 var g=document.getElementById('scheme-grid');
11881 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);}});
11882 var cl=document.getElementById('settings-close');
11883 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);}});}})();
11884 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');}});
11885 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11886 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11887 }})();
11888 }})();
11889
11890 var ROOTS = {roots_json};
11891 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11892 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11893 var allData = [];
11894
11895 // Populate root selector
11896 var rootSel = document.getElementById('root-sel');
11897 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11898
11899 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();}}
11900 function fmtFull(n){{return Number(n).toLocaleString();}}
11901 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
11902
11903 // Tooltip
11904 var tt = document.createElement('div');
11905 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);';
11906 document.body.appendChild(tt);
11907 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11908 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';}}
11909 function hideTT(){{tt.style.display='none';}}
11910 window.addEventListener('blur',function(){{hideTT();}});
11911 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11912
11913 function statExact(compact, full){{
11914 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11915 }}
11916 function statVal(n){{
11917 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11918 }}
11919
11920 function updateStats(data){{
11921 var statsEl=document.getElementById('trend-stats');
11922 if(!statsEl)return;
11923 if(!data||!data.length){{statsEl.innerHTML='';return;}}
11924 var yKey=document.getElementById('y-sel').value;
11925 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11926 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11927 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11928 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11929 var absDelta=Math.abs(delta);
11930 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11931 var deltaExact=statExact(deltaCompact,deltaFull);
11932 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11933 statsEl.innerHTML=
11934 '<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>'+
11935 '<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>'+
11936 '<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>'+
11937 '<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>';
11938 }}
11939
11940 var subSel = document.getElementById('sub-sel');
11941 var subLabel = document.getElementById('submodule-label');
11942
11943 function populateSubmodules(root){{
11944 if(!subSel||!subLabel)return;
11945 while(subSel.options.length>1)subSel.remove(1);
11946 subSel.value='';
11947 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11948 fetch(url)
11949 .then(function(r){{return r.json();}})
11950 .then(function(subs){{
11951 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11952 subs.forEach(function(s){{
11953 var o=document.createElement('option');
11954 o.value=s.name;
11955 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11956 subSel.appendChild(o);
11957 }});
11958 subLabel.style.display='';
11959 }})
11960 .catch(function(){{subLabel.style.display='none';}});
11961 }}
11962
11963 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11964
11965 function loadAndRender(){{
11966 var root = rootSel.value;
11967 var sub = subSel ? subSel.value : '';
11968 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11969 document.getElementById('data-table-wrap').innerHTML='';
11970 var url = '/api/metrics/history?limit=100'
11971 + (root ? '&root='+encodeURIComponent(root) : '')
11972 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
11973 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11974 allData = data;
11975 render(data);
11976 updateStats(data);
11977 }}).catch(function(){{
11978 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>';
11979 }});
11980 }}
11981
11982 function render(data){{
11983 var yKey = document.getElementById('y-sel').value;
11984 var xMode = document.getElementById('x-sel').value;
11985
11986 // Filter for tag/release mode
11987 var pts = data;
11988 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11989
11990 // Sort oldest-first for the line chart
11991 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11992
11993 var wrap = document.getElementById('chart-wrap');
11994 if(!pts.length){{
11995 var emptyMsg = (xMode === 'tag')
11996 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11997 : 'No scan data found for the selected filters.';
11998 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11999 renderTable([]);
12000 return;
12001 }}
12002
12003 var scaleEl=document.getElementById('scale-sel');
12004 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12005 renderTrendInto(wrap, pts, yKey, xMode, sc);
12006 renderTable(pts, yKey);
12007 }}
12008
12009 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12010 // Shared by the inline chart and the Full View modal so both render identically.
12011 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12012 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12013 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12014 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12015 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;
12016 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12017
12018 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12019
12020 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">';
12021 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>';
12022
12023 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12024
12025 // Grid + Y axis ticks
12026 for(var ti=0;ti<=5;ti++){{
12027 var gy=PT+CH-Math.round(ti/5*CH);
12028 var gv=Math.round(ti/5*maxY);
12029 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12030 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12031 }}
12032
12033 // X axis labels (every N-th point to avoid crowding)
12034 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12035 pts.forEach(function(d,i){{
12036 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12037 if(i%labelEvery===0||i===pts.length-1){{
12038 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)));
12039 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>';
12040 }}
12041 }});
12042
12043 // Axis label
12044 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12045 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>';
12046 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>';
12047
12048 // Area fill + line path
12049 var pathD='';
12050 pts.forEach(function(d,i){{
12051 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12052 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12053 pathD+=(i===0?'M':'L')+x+','+y;
12054 }});
12055 if(pts.length>1){{
12056 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12057 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12058 }}
12059 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12060
12061 // Data points (clickable) + permanent value labels
12062 var showLabels = pts.length <= 40;
12063 var labelEveryN = pts.length > 20 ? 2 : 1;
12064 pts.forEach(function(d,i){{
12065 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12066 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12067 var hasTags=d.tags&&d.tags.length>0;
12068 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12069 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12070 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+'"/>';
12071 if(showLabels && i%labelEveryN===0){{
12072 var lx=x, ly=y-r-5;
12073 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>';
12074 }}
12075 }});
12076
12077 svg+='</svg>';
12078 wrap.innerHTML=svg;
12079
12080 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12081 function lineYAt(mx){{
12082 var n=pts.length;
12083 if(n===0)return PT+CH;
12084 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12085 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12086 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12087 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12088 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12089 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12090 return y0+t*(y1-y0);
12091 }}
12092
12093 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12094 // gradient fill (inside the chart and at/below the line) — never in the empty
12095 // space above the line. Cursor follows the same rule.
12096 (function(){{
12097 var svgEl=wrap.querySelector('svg');
12098 if(!svgEl)return;
12099 svgEl.addEventListener('mousemove',function(e){{
12100 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12101 var rect=svgEl.getBoundingClientRect();
12102 var scaleX=W/Math.max(rect.width,1);
12103 var scaleY=H/Math.max(rect.height,1);
12104 var mouseX=(e.clientX-rect.left)*scaleX;
12105 var mouseY=(e.clientY-rect.top)*scaleY;
12106 var ly=lineYAt(mouseX);
12107 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12108 svgEl.style.cursor='pointer';
12109 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12110 var d=pts[idx];
12111 var val=Number(d[yKey]);
12112 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12113 showTT(e,
12114 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12115 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12116 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12117 );
12118 }});
12119 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12120 }})();
12121
12122 // Attach point tooltips
12123 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12124 c.addEventListener('mouseover',function(e){{
12125 var d=pts[parseInt(this.dataset.idx)];
12126 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(''):'';
12127 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>':'';
12128 showTT(e,
12129 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12130 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12131 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12132 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12133 );
12134 this.setAttribute('r','8');
12135 }});
12136 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12137 c.addEventListener('mousemove',moveTT);
12138 c.addEventListener('click',function(){{
12139 var d=pts[parseInt(this.dataset.idx)];
12140 if(d.html_url) window.open(d.html_url,'_blank');
12141 }});
12142 }});
12143 }}
12144
12145 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12146 var shProjFilter='', shBranchFilter='';
12147
12148 function fmtPST(isoStr){{
12149 if(!isoStr)return'';
12150 var d=new Date(isoStr);
12151 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12152 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);}}
12153 function p(n){{return n<10?'0'+n:String(n);}}
12154 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++;}}}}
12155 var yr=d.getUTCFullYear();
12156 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12157 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12158 var isDST=d>=dstStart&&d<dstEnd;
12159 var off=isDST?-7*3600*1000:-8*3600*1000;
12160 var lbl=isDST?'PDT':'PST';
12161 var loc=new Date(d.getTime()+off);
12162 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12163 }}
12164
12165 function getShRows(){{
12166 var proj=shProjFilter.toLowerCase().trim();
12167 var branch=shBranchFilter;
12168 return shData.filter(function(d){{
12169 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12170 if(branch&&(d.branch||'')!==branch)return false;
12171 return true;
12172 }});
12173 }}
12174
12175 function renderShPage(){{
12176 var filtered=getShRows();
12177 if(shSortCol){{
12178 filtered.sort(function(a,b){{
12179 var va,vb;
12180 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12181 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12182 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12183 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12184 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12185 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12186 }});
12187 }}
12188 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12189 shPage=Math.min(shPage,totalPages);
12190 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12191 var visible=filtered.slice(start,end);
12192 var tbody=document.getElementById('sh-tbody');
12193 if(!tbody)return;
12194 tbody.innerHTML=visible.map(function(d){{
12195 var tsHtml=esc(fmtPST(d.timestamp));
12196 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>';
12197 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>';
12198 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12199 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12200 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12201 var reportCell='';
12202 if(d.html_url){{
12203 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12204 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>';}}
12205 reportCell+='</div>';
12206 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12207 if(d.submodule_links&&d.submodule_links.length){{
12208 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12209 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12210 reportCell+='</div></details>';
12211 }}
12212 return '<tr>'
12213 +'<td>'+tsHtml+'</td>'
12214 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12215 +'<td>'+runIdHtml+'</td>'
12216 +'<td>'+commitHtml+'</td>'
12217 +'<td>'+branchHtml+'</td>'
12218 +'<td>'+tags+'</td>'
12219 +'<td class="num">'+metricHtml+'</td>'
12220 +'<td class="report-cell">'+reportCell+'</td>'
12221 +'</tr>';
12222 }}).join('');
12223 var pgRange=document.getElementById('sh-pg-range');
12224 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12225 var pgInfo=document.getElementById('sh-pg-info');
12226 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12227 var pgBtns=document.getElementById('sh-pg-btns');
12228 if(pgBtns){{
12229 pgBtns.innerHTML='';
12230 function mkPgBtn(lbl,pg,active,disabled){{
12231 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12232 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12233 return b;
12234 }}
12235 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12236 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12237 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12238 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12239 }}
12240 }}
12241
12242 function wireTableBehavior(){{
12243 var pf=document.getElementById('sh-proj-filter');
12244 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12245 var bf=document.getElementById('sh-branch-filter');
12246 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12247 var rb=document.getElementById('sh-reset-btn');
12248 if(rb)rb.addEventListener('click',function(){{
12249 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12250 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12251 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12252 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');}});
12253 renderShPage();
12254 }});
12255 var pps=document.getElementById('sh-per-page');
12256 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12257 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12258 ths.forEach(function(th){{
12259 th.addEventListener('click',function(e){{
12260 if(e.target.classList.contains('col-resize-handle'))return;
12261 var col=th.dataset.col;
12262 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12263 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12264 th.classList.add('sort-'+shSortOrder);
12265 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12266 shPage=1;renderShPage();
12267 }});
12268 }});
12269 var table=document.getElementById('scan-history-table');
12270 if(!table)return;
12271 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12272 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12273 allThs.forEach(function(th,i){{
12274 var handle=th.querySelector('.col-resize-handle');
12275 if(!handle||!cols[i])return;
12276 var startX,startW;
12277 handle.addEventListener('mousedown',function(e){{
12278 e.stopPropagation();e.preventDefault();
12279 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12280 handle.classList.add('dragging');
12281 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12282 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12283 document.addEventListener('mousemove',onMove);
12284 document.addEventListener('mouseup',onUp);
12285 }});
12286 }});
12287 }}
12288
12289 function renderTable(pts, yKey){{
12290 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12291 var wrap=document.getElementById('data-table-wrap');
12292 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12293 var yLabel=Y_LABELS[yKey]||yKey||'';
12294 shData=pts.slice().reverse();
12295 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12296 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12297 var branches={{}};
12298 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12299 var branchOpts='<option value="">All branches</option>';
12300 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12301 wrap.innerHTML=
12302 '<div class="chart-section-header">SCAN HISTORY</div>'+
12303 '<div class="filter-row">'+
12304 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12305 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12306 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12307 '</div>'+
12308 '<div class="table-wrap">'+
12309 '<table id="scan-history-table" class="data-table">'+
12310 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12311 '<thead><tr id="sh-thead">'+
12312 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12313 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12314 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12315 '<th>Commit<div class="col-resize-handle"></div></th>'+
12316 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12317 '<th>Tags<div class="col-resize-handle"></div></th>'+
12318 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12319 '<th>Report<div class="col-resize-handle"></div></th>'+
12320 '</tr></thead>'+
12321 '<tbody id="sh-tbody"></tbody>'+
12322 '</table>'+
12323 '</div>'+
12324 '<div class="pagination">'+
12325 '<span class="pagination-info" id="sh-pg-info"></span>'+
12326 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12327 '<div style="display:flex;align-items:center;gap:8px;">'+
12328 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12329 '<select class="filter-select" id="sh-per-page">'+
12330 '<option value="10">10 per page</option>'+
12331 '<option value="25" selected>25 per page</option>'+
12332 '<option value="50">50 per page</option>'+
12333 '<option value="100">100 per page</option>'+
12334 '</select>'+
12335 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12336 '</div>'+
12337 '</div>';
12338 wireTableBehavior();
12339 renderShPage();
12340 }}
12341
12342 function exportXLSX(){{
12343 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12344 var xbtn=document.getElementById('export-xlsx-btn');
12345 var xorig=xbtn?xbtn.innerHTML:'';
12346 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12347 var root=rootSel.value;
12348 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12349 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12350 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12351 buildAndDownloadXLSX(cm);
12352 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12353 }}
12354
12355 function buildAndDownloadXLSX(churnMap){{
12356 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12357 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12358 // (sorted is newest-first), so a given project/commit appears at most once.
12359 var seenPC={{}},dedup=[];
12360 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12361 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12362 var s1R=dedup.map(function(d){{
12363 var c=churnMap[d.run_id]||{{}};
12364 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];
12365 }});
12366 var pm={{}};
12367 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12368 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'];
12369 var s2R=Object.keys(pm).map(function(p){{
12370 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12371 var lat=sc[sc.length-1],fst=sc[0];
12372 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12373 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);
12374 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];
12375 }});
12376 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12377 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12378 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12379 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12380 }}
12381
12382 function buildXLSX(sheets,chartRows,chartRows2){{
12383 function s2b(s){{return new TextEncoder().encode(s);}}
12384 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12385 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;}}
12386 function crc32(d){{
12387 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;}}}}
12388 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12389 }}
12390 function buildSheet(hdr,rows,drawRid,withCtrl){{
12391 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12392 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12393 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12394 x+='<row r="1">';
12395 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12396 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12397 x+='</row>';
12398 rows.forEach(function(row,ri){{
12399 var rn=ri+2;
12400 x+='<row r="'+rn+'">';
12401 row.forEach(function(cell,ci){{
12402 var addr=col2l(ci+1)+rn;
12403 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12404 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12405 }});
12406 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\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12407 x+='</row>';
12408 }});
12409 x+='</sheetData>';
12410 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12411 return x+'</worksheet>';
12412 }}
12413 function buildChartXML(rows){{
12414 var sn="'Scan History'";
12415 var nr=rows.length,er=nr+1;
12416 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'}}];
12417 var catCol='C',catIdx=2;
12418 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12419 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">';
12420 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12421 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>';
12422 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12423 sd.forEach(function(s,i){{
12424 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12425 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>';
12426 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12427 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>';
12428 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12429 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12430 x+='</c:strCache></c:strRef></c:cat>';
12431 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+'"/>';
12432 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12433 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12434 }});
12435 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12436 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>';
12437 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>';
12438 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12439 return x;
12440 }}
12441 function buildChartXML2(rows){{
12442 var sn="'By Project'";
12443 var nr=rows.length,er=nr+1;
12444 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'}}];
12445 var catCol='A',catIdx=0;
12446 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12447 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">';
12448 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12449 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>';
12450 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12451 sd.forEach(function(s,i){{
12452 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12453 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>';
12454 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12455 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>';
12456 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12457 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12458 x+='</c:strCache></c:strRef></c:cat>';
12459 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+'"/>';
12460 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12461 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12462 }});
12463 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12464 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>';
12465 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>';
12466 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12467 return x;
12468 }}
12469 function buildChartXML3(rows){{
12470 var sn="'Scan History'";
12471 var nr=rows.length,er=nr+1;
12472 var catCol='C',catIdx=2;
12473 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12474 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">';
12475 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12476 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12477 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12478 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>";
12479 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12480 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>';
12481 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>';
12482 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12483 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12484 x+='</c:strCache></c:strRef></c:cat>';
12485 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+'"/>';
12486 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12487 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12488 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12489 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>';
12490 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>';
12491 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>';
12492 return x;
12493 }}
12494 function buildFocusSheet(drawRid){{
12495 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12496 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12497 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12498 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12499 x+='<sheetData><row r="1">';
12500 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12501 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12502 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12503 x+='</row></sheetData>';
12504 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"</formula1></dataValidation></dataValidations>';
12505 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12506 return x+'</worksheet>';
12507 }}
12508 var hasChart=!!(chartRows&&chartRows.length);
12509 var nr=hasChart?chartRows.length:0;
12510 var hasChart2=!!(chartRows2&&chartRows2.length);
12511 var nr2=hasChart2?chartRows2.length:0;
12512 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>';
12513 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"/>';
12514 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12515 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"/>';}}
12516 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"/>';}}
12517 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12518 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>';
12519 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12520 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"/>';}});
12521 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12522 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>';
12523 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12524 wbx+='</sheets></workbook>';
12525 var files=[
12526 {{name:'[Content_Types].xml',data:s2b(ct)}},
12527 {{name:'_rels/.rels',data:s2b(dotrels)}},
12528 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12529 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12530 {{name:'xl/styles.xml',data:s2b(styl)}}
12531 ];
12532 // Chart embedded directly in Scan History (sheet1); By Project is plain
12533 sheets.forEach(function(s,i){{
12534 var sx;
12535 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12536 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12537 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12538 }});
12539 if(hasChart){{
12540 var fromRow=nr+4,toRow=nr+34;
12541 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>')}});
12542 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12543 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">';
12544 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12545 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>';
12546 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>';
12547 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12548 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12549 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12550 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12551 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12552 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12553 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>')}});
12554 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12555 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>')}});
12556 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12557 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">';
12558 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12559 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>';
12560 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>';
12561 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12562 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12563 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12564 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12565 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12566 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12567 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>')}});
12568 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12569 }}
12570 if(hasChart2){{
12571 var fromRow2=nr2+4,toRow2=nr2+36;
12572 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>')}});
12573 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12574 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">';
12575 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12576 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>';
12577 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>';
12578 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12579 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12580 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12581 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12582 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12583 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12584 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>')}});
12585 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12586 }}
12587 var parts=[],offsets=[],total=0;
12588 files.forEach(function(f){{
12589 offsets.push(total);
12590 var nb=s2b(f.name),crc=crc32(f.data);
12591 var h=new DataView(new ArrayBuffer(30+nb.length));
12592 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12593 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12594 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12595 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12596 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12597 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12598 total+=30+nb.length+f.data.length;
12599 }});
12600 var cdStart=total;
12601 files.forEach(function(f,fi){{
12602 var nb=s2b(f.name),crc=crc32(f.data);
12603 var cd=new DataView(new ArrayBuffer(46+nb.length));
12604 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12605 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12606 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12607 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12608 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12609 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12610 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12611 }});
12612 var cdSz=total-cdStart;
12613 var eocd=new DataView(new ArrayBuffer(22));
12614 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12615 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12616 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12617 parts.push(new Uint8Array(eocd.buffer));
12618 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12619 var out=new Uint8Array(sz);var off=0;
12620 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12621 return out.buffer;
12622 }}
12623
12624 function trendTitleParts(){{
12625 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12626 var subSelEl=document.getElementById('sub-sel');
12627 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12628 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12629 var proj=(document.getElementById('root-sel').value)||'All projects';
12630 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12631 var cnt=(allData&&allData.length)||0;
12632 var now=new Date();
12633 function p2(n){{return(n<10?'0':'')+n;}}
12634 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12635 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12636 }}
12637
12638 function exportPNG(){{
12639 var svgEl=document.querySelector('#chart-wrap svg');
12640 if(!svgEl){{alert('No chart to export yet.');return;}}
12641 var svgStr=new XMLSerializer().serializeToString(svgEl);
12642 var vb=svgEl.viewBox.baseVal,scale=2;
12643 var headerH=84,footerH=36;
12644 var lw=(vb.width||900),lh=(vb.height||380);
12645 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12646 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12647 var url=URL.createObjectURL(blob);
12648 var img=new Image();
12649 var tp=trendTitleParts();
12650 img.onload=function(){{
12651 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12652 var ctx=canvas.getContext('2d');
12653 var cs=getComputedStyle(document.body);
12654 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12655 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12656 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12657 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12658 ctx.scale(scale,scale);
12659 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12660 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12661 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12662 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12663 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;
12664 ctx.drawImage(img,0,headerH);
12665 var fy=headerH+lh;
12666 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;
12667 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12668 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);
12669 ctx.textAlign='left';
12670 URL.revokeObjectURL(url);
12671 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12672 }};
12673 img.src=url;
12674 }}
12675
12676 function exportPDF(){{
12677 var svgEl=document.querySelector('#chart-wrap svg');
12678 if(!svgEl){{alert('No chart to export yet.');return;}}
12679 var tp=trendTitleParts();
12680 var svgStr=new XMLSerializer().serializeToString(svgEl);
12681 var statsEl=document.getElementById('trend-stats');
12682 var statsHtml=statsEl?statsEl.innerHTML:'';
12683 var yK=document.getElementById('y-sel').value;
12684 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12685 var yL=yLabels[yK]||yK;
12686 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12687 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>';
12688 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>';}});
12689 tableHtml+='</tbody></table>';
12690 var css='<style>'
12691 +'*{{box-sizing:border-box;}}'
12692 +'html,body{{margin:0;padding:0;}}'
12693 // Masthead/footer flow in document order — a position:fixed header repeats
12694 // on every printed page in Chromium and hides the rows beneath it on pages
12695 // 2+. The trend table's <thead> repeats per page natively instead.
12696 +'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;}}'
12697 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12698 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12699 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12700 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12701 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12702 +'.rep-body{{padding:22px 34px 0;}}'
12703 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12704 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12705 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12706 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12707 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12708 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12709 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12710 +'.stat-chip-tip{{display:none!important;}}'
12711 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12712 +'.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;}}'
12713 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12714 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12715 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12716 +'.rep-chart svg{{max-width:100%;height:auto;}}'
12717 +'.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;}}'
12718 +'.filter-row{{display:none!important;}}'
12719 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12720 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12721 +'th{{background:#f0e9e0;font-weight:800;}}'
12722 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12723 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12724 +'.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;}}'
12725 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12726 +'</style>';
12727 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12728 +'<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>'
12729 +'<div class="rep-body">'
12730 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12731 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12732 +'<div class="summary-strip">'+statsHtml+'</div>'
12733 +'<div class="rep-chart">'+svgStr+'</div>'
12734 +tableHtml
12735 +'</div>'
12736 +'<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>'
12737 +'</body></html>';
12738 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12739 }}
12740
12741 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12742 var el=document.getElementById(id);
12743 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12744 }});
12745 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12746 // tracks the container like the responsive Chart.js charts do.
12747 var _rsT=null;
12748 window.addEventListener('resize',function(){{
12749 if(_rsT)clearTimeout(_rsT);
12750 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12751 }});
12752 rootSel.addEventListener('change',function(){{
12753 populateSubmodules(rootSel.value);
12754 loadAndRender();
12755 }});
12756 if(subSel)subSel.addEventListener('change',loadAndRender);
12757
12758 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12759 (function(){{
12760 var fvBtn=document.getElementById('tr-chart-fv-btn');
12761 if(!fvBtn)return;
12762 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12763 fvBtn.addEventListener('click',function(){{
12764 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12765 var yKey=document.getElementById('y-sel').value;
12766 var xMode=document.getElementById('x-sel').value;
12767 var pts=allData;
12768 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12769 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12770 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12771 var tp=trendTitleParts();
12772 var ov=document.createElement('div');
12773 ov.className='tr-chart-full-modal';
12774 ov.innerHTML='<div class="tr-chart-full-inner">'
12775 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12776 +'<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>'
12777 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12778 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12779 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12780 document.body.appendChild(ov);
12781 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12782 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12783 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12784 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12785 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12786 }});
12787 }})();
12788
12789 var xlsxBtn=document.getElementById('export-xlsx-btn');
12790 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12791 var pngBtn=document.getElementById('export-png-btn');
12792 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12793 var pdfBtn=document.getElementById('export-pdf-btn');
12794 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12795
12796 // ── Clean-up modal ───────────────────────────────────────────────────────
12797 (function(){{
12798 var triggerBtn=document.getElementById('cleanup-runs-btn');
12799 if(!triggerBtn)return;
12800 var modal=document.createElement('div');
12801 modal.className='tr-modal-backdrop';
12802 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12803 +'<div class="tr-modal-head">'
12804 +'<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>'
12805 +'<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>'
12806 +'</div>'
12807 +'<div class="tr-modal-body">'
12808 +'<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>'
12809 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12810 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12811 +'<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;">'
12812 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12813 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12814 +'</div>'
12815 +'<div class="tr-modal-foot">'
12816 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12817 +'<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>'
12818 +'</div></div>';
12819 document.body.appendChild(modal);
12820 triggerBtn.addEventListener('click',function(){{
12821 document.getElementById('cleanup-status').style.display='none';
12822 modal.style.display='flex';
12823 }});
12824 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12825 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12826 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12827 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12828 var confirmBtn=this;
12829 confirmBtn.disabled=true;
12830 var status=document.getElementById('cleanup-status');
12831 status.style.display='block';
12832 status.style.background='#dbeafe';status.style.color='#1e40af';
12833 status.textContent='Deleting\u2026';
12834 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12835 .then(function(resp){{
12836 return resp.json().then(function(d){{
12837 if(resp.ok){{
12838 status.style.background='#dcfce7';status.style.color='#166534';
12839 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12840 setTimeout(function(){{window.location.reload();}},1500);
12841 }}else{{
12842 status.style.background='#fee2e2';status.style.color='#991b1b';
12843 status.textContent='Error: '+(d.error||'Unexpected error');
12844 confirmBtn.disabled=false;
12845 }}
12846 }});
12847 }})
12848 .catch(function(e){{
12849 status.style.background='#fee2e2';status.style.color='#991b1b';
12850 status.textContent='Network error: '+String(e);
12851 confirmBtn.disabled=false;
12852 }});
12853 }});
12854 }})();
12855
12856 // ── Retention policy panel ────────────────────────────────────────────────
12857 (function(){{
12858 var triggerBtn=document.getElementById('retention-policy-btn');
12859 if(!triggerBtn)return;
12860 var modal=document.createElement('div');
12861 modal.className='tr-modal-backdrop';
12862 modal.style.zIndex='9001';
12863 modal.innerHTML=''
12864 +'<div class="tr-modal" style="max-width:640px;">'
12865 +'<div class="tr-modal-head">'
12866 +'<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>'
12867 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12868 +'</div>'
12869 +'<div class="tr-modal-body">'
12870 +'<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>'
12871 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12872 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12873 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12874 +'</div>'
12875 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12876 +'<div>'
12877 +'<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>'
12878 +'<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;">'
12879 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12880 +'</div>'
12881 +'<div>'
12882 +'<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>'
12883 +'<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;">'
12884 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12885 +'</div>'
12886 +'</div>'
12887 +'<div style="margin-bottom:20px;">'
12888 +'<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>'
12889 +'<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;">'
12890 +'<option value="1">Every hour</option>'
12891 +'<option value="6">Every 6 hours</option>'
12892 +'<option value="12">Every 12 hours</option>'
12893 +'<option value="24" selected>Every 24 hours</option>'
12894 +'<option value="48">Every 2 days</option>'
12895 +'<option value="72">Every 3 days</option>'
12896 +'<option value="168">Every week</option>'
12897 +'</select>'
12898 +'</div>'
12899 +'<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>'
12900 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12901 +'</div>'
12902 +'<div class="tr-modal-foot">'
12903 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12904 +'<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>'
12905 +'<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>'
12906 +'</div>'
12907 +'</div>';
12908 document.body.appendChild(modal);
12909
12910 function rpShowStatus(msg,ok){{
12911 var s=document.getElementById('rp-status');
12912 s.style.display='block';
12913 s.style.background=ok?'#dcfce7':'#fee2e2';
12914 s.style.color=ok?'#166534':'#991b1b';
12915 s.textContent=msg;
12916 }}
12917 function fmtAgo(iso){{
12918 if(!iso)return'Never';
12919 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12920 if(diff<60)return diff+'s ago';
12921 if(diff<3600)return Math.floor(diff/60)+'m ago';
12922 if(diff<86400)return Math.floor(diff/3600)+'h ago';
12923 return Math.floor(diff/86400)+'d ago';
12924 }}
12925 function loadPolicy(){{
12926 fetch('/api/cleanup-policy')
12927 .then(function(r){{return r.json();}})
12928 .then(function(d){{
12929 var p=d.policy;
12930 document.getElementById('rp-enabled').checked=p?p.enabled:false;
12931 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12932 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12933 var sel=document.getElementById('rp-interval');
12934 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;}}}}}}
12935 var lr=document.getElementById('rp-last-run');
12936 if(d.last_run_at){{
12937 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'):'');
12938 }}else{{
12939 lr.textContent='Auto-cleanup has not run yet.';
12940 }}
12941 }})
12942 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12943 }}
12944
12945 triggerBtn.addEventListener('click',function(){{
12946 document.getElementById('rp-status').style.display='none';
12947 loadPolicy();
12948 modal.style.display='flex';
12949 }});
12950 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12951 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12952
12953 document.getElementById('rp-save-btn').addEventListener('click',function(){{
12954 var enabled=document.getElementById('rp-enabled').checked;
12955 var ageVal=document.getElementById('rp-max-age').value.trim();
12956 var countVal=document.getElementById('rp-max-count').value.trim();
12957 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12958 if(enabled&&!ageVal&&!countVal){{
12959 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12960 return;
12961 }}
12962 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12963 var saveBtn=document.getElementById('rp-save-btn');
12964 saveBtn.disabled=true;
12965 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12966 .then(function(r){{
12967 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12968 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12969 }})
12970 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12971 .finally(function(){{saveBtn.disabled=false;}});
12972 }});
12973
12974 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12975 var btn=this;
12976 var orig=btn.innerHTML;
12977 btn.disabled=true;
12978 btn.textContent='Running\u2026';
12979 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12980 .then(function(r){{return r.json();}})
12981 .then(function(d){{
12982 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12983 loadPolicy();
12984 }})
12985 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12986 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12987 }});
12988 }})();
12989
12990 populateSubmodules(rootSel.value);
12991 loadAndRender();
12992
12993 (function randomizeWatermarks() {{
12994 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12995 if (!wms.length) return;
12996 var placed = [];
12997 function tooClose(top, left) {{
12998 for (var i = 0; i < placed.length; i++) {{
12999 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13000 if (dt < 16 && dl < 12) return true;
13001 }}
13002 return false;
13003 }}
13004 function pick(leftBand) {{
13005 for (var attempt = 0; attempt < 50; attempt++) {{
13006 var top = Math.random() * 88 + 2;
13007 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13008 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13009 }}
13010 var top = Math.random() * 88 + 2;
13011 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13012 placed.push([top, left]); return [top, left];
13013 }}
13014 var half = Math.floor(wms.length / 2);
13015 wms.forEach(function (img, i) {{
13016 var pos = pick(i < half);
13017 var size = Math.floor(Math.random() * 100 + 120);
13018 var rot = (Math.random() * 360).toFixed(1);
13019 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13020 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;
13021 }});
13022 }})();
13023 (function spawnCodeParticles() {{
13024 var container = document.getElementById('code-particles');
13025 if (!container) return;
13026 var snippets = [
13027 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13028 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13029 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13030 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13031 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13032 ];
13033 var count = 38;
13034 for (var i = 0; i < count; i++) {{
13035 (function(idx) {{
13036 var el = document.createElement('span');
13037 el.className = 'code-particle';
13038 el.textContent = snippets[idx % snippets.length];
13039 var left = Math.random() * 94 + 2;
13040 var top = Math.random() * 88 + 6;
13041 var dur = (Math.random() * 10 + 9).toFixed(1);
13042 var delay = (Math.random() * 18).toFixed(1);
13043 var rot = (Math.random() * 26 - 13).toFixed(1);
13044 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13045 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13046 container.appendChild(el);
13047 }})(i);
13048 }}
13049 }})();
13050 </script>
13051 <footer class="site-footer">
13052 local code analysis - metrics, history and reports
13053 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13054 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13055 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13056 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13057 · <a href="/api-docs" rel="noopener">REST API</a>
13058 </footer>
13059 <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>
13060 {toast_assets}
13061</body>
13062</html>"##,
13063 );
13064
13065 Html(html).into_response()
13066}
13067
13068fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13069 use std::collections::HashMap;
13070 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13071 return vec![];
13072 }
13073 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13074 for rec in per_file_records {
13075 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13076 let e = totals.entry(lang.display_name().to_string()).or_default();
13077 e.0 += u64::from(cov.lines_found);
13078 e.1 += u64::from(cov.lines_hit);
13079 }
13080 }
13081 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13083 .into_iter()
13084 .filter(|(_, (found, _))| *found > 0)
13085 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13086 .collect();
13087 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13088 pairs
13089 .iter()
13090 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13091 .collect()
13092}
13093
13094fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13095 let mut high = 0u64;
13096 let mut mid = 0u64;
13097 let mut low = 0u64;
13098 for rec in per_file_records {
13099 if let Some(cov) = &rec.coverage {
13100 if cov.lines_found == 0 {
13101 continue;
13102 }
13103 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13104 if pct >= 80.0 {
13105 high += 1;
13106 } else if pct >= 50.0 {
13107 mid += 1;
13108 } else {
13109 low += 1;
13110 }
13111 }
13112 }
13113 (high, mid, low)
13114}
13115
13116fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13117 let mut arr: Vec<serde_json::Value> = per_file_records
13118 .iter()
13119 .filter_map(|rec| {
13120 rec.coverage.as_ref().map(|cov| {
13121 let line_pct = if cov.lines_found > 0 {
13122 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13123 / 10.0
13124 } else {
13125 0.0
13126 };
13127 let fn_pct = if cov.functions_found > 0 {
13128 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13129 .round()
13130 / 10.0
13131 } else {
13132 -1.0
13133 };
13134 serde_json::json!({
13135 "rel": rec.relative_path,
13136 "lang": rec.language.map_or("?", |l| l.display_name()),
13137 "line_pct": line_pct,
13138 "fn_pct": fn_pct,
13139 "lhit": cov.lines_hit,
13140 "lfound": cov.lines_found,
13141 "fhit": cov.functions_hit,
13142 "ffound": cov.functions_found,
13143 })
13144 })
13145 })
13146 .collect();
13147 arr.sort_by(|a, b| {
13148 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13149 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13150 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13151 });
13152 arr
13153}
13154
13155#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13157 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13158 .totals_by_language
13159 .iter()
13160 .filter(|l| l.test_count > 0)
13161 .collect();
13162 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13163 let lang_tests: Vec<serde_json::Value> = langs
13164 .iter()
13165 .map(|l| {
13166 let d = if l.code_lines > 0 {
13167 l.test_count as f64 / l.code_lines as f64 * 1000.0
13168 } else {
13169 0.0
13170 };
13171 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13172 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13173 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13174 })
13175 .collect();
13176 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13177 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13178 let t = &run.summary_totals;
13179 let total_tests = t.test_count;
13180 let density = if t.code_lines > 0 {
13181 total_tests as f64 / t.code_lines as f64 * 1000.0
13182 } else {
13183 0.0
13184 };
13185 let most_tested = langs.first().map_or_else(
13186 || "\u{2014}".to_string(),
13187 |l| l.language.display_name().to_string(),
13188 );
13189 let test_files: u64 = run
13190 .per_file_records
13191 .iter()
13192 .filter(|f| f.raw_line_categories.test_count > 0)
13193 .count() as u64;
13194 let cov_line = if t.coverage_lines_found > 0 {
13195 format!(
13196 "{:.1}",
13197 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13198 )
13199 } else {
13200 "0".to_string()
13201 };
13202 let cov_fn = if t.coverage_functions_found > 0 {
13203 format!(
13204 "{:.1}",
13205 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13206 )
13207 } else {
13208 "0".to_string()
13209 };
13210 let cov_branch = if t.coverage_branches_found > 0 {
13211 format!(
13212 "{:.1}",
13213 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13214 )
13215 } else {
13216 "0".to_string()
13217 };
13218 let has_cov = !cov_arr.is_empty();
13219 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13220 serde_json::json!({
13221 "totals": {
13222 "test_count": total_tests,
13223 "assertions": t.test_assertion_count,
13224 "suites": t.test_suite_count,
13225 "test_files": test_files,
13226 "total_files": t.files_analyzed,
13227 "density_str": format!("{density:.1}"),
13228 "most_tested": most_tested,
13229 "langs_with_tests": langs.len(),
13230 "cov_line": cov_line,
13231 "cov_fn": cov_fn,
13232 "cov_branch": cov_branch,
13233 },
13234 "lang_tests": lang_tests,
13235 "cov": cov_arr,
13236 "cov_tiers": {"high": high, "mid": mid, "low": low},
13237 "file_cov": file_cov_arr,
13238 "has_coverage": has_cov,
13239 "submodules": {},
13240 })
13241}
13242
13243#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13245 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13246 .language_summaries
13247 .iter()
13248 .filter(|l| l.test_count > 0)
13249 .collect();
13250 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13251 let lang_tests: Vec<serde_json::Value> = langs
13252 .iter()
13253 .map(|l| {
13254 let d = if l.code_lines > 0 {
13255 l.test_count as f64 / l.code_lines as f64 * 1000.0
13256 } else {
13257 0.0
13258 };
13259 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13260 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13261 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13262 })
13263 .collect();
13264 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13265 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13266 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13267 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13268 let density = if sub.code_lines > 0 {
13269 total_tests as f64 / sub.code_lines as f64 * 1000.0
13270 } else {
13271 0.0
13272 };
13273 let most_tested = langs.first().map_or_else(
13274 || "\u{2014}".to_string(),
13275 |l| l.language.display_name().to_string(),
13276 );
13277 serde_json::json!({
13278 "totals": {
13279 "test_count": total_tests,
13280 "assertions": total_assertions,
13281 "suites": total_suites,
13282 "test_files": test_files_approx,
13283 "total_files": sub.files_analyzed,
13284 "density_str": format!("{density:.1}"),
13285 "most_tested": most_tested,
13286 "langs_with_tests": langs.len(),
13287 "cov_line": "0",
13288 "cov_fn": "0",
13289 "cov_branch": "0",
13290 },
13291 "lang_tests": lang_tests,
13292 "cov": [],
13293 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13294 "has_coverage": false,
13295 })
13296}
13297
13298fn compute_cov_json_str(run: &AnalysisRun) -> String {
13299 use std::collections::HashMap;
13300 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13301 for rec in &run.per_file_records {
13302 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13303 let e = totals.entry(lang.display_name().to_string()).or_default();
13304 e.0 += u64::from(cov.lines_found);
13305 e.1 += u64::from(cov.lines_hit);
13306 }
13307 }
13308 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13310 .into_iter()
13311 .filter(|(_, (found, _))| *found > 0)
13312 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13313 .collect();
13314 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13315 let parts: Vec<String> = pairs
13316 .iter()
13317 .map(|(lang, pct)| {
13318 let name = lang.replace('"', "\\\"");
13319 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13320 })
13321 .collect();
13322 format!("[{}]", parts.join(","))
13323}
13324
13325fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13326 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13327 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13328}
13329
13330fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13331 let mut entry = build_test_scope_entry(run);
13332 if !run.submodule_summaries.is_empty() {
13333 let subs: serde_json::Map<String, serde_json::Value> = run
13334 .submodule_summaries
13335 .iter()
13336 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13337 .collect();
13338 entry["submodules"] = serde_json::Value::Object(subs);
13339 }
13340 entry
13341}
13342
13343fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13344 let name = l.language.display_name().replace('"', "\\\"");
13345 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13347 l.test_count as f64 / l.code_lines as f64 * 1000.0
13348 } else {
13349 0.0
13350 };
13351 format!(
13352 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13353 name = name,
13354 t = l.test_count,
13355 a = l.test_assertion_count,
13356 s = l.test_suite_count,
13357 c = l.code_lines,
13358 d = density,
13359 f = l.files,
13360 )
13361}
13362
13363fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13364 let Some(r) = run else {
13365 return "[]".to_string();
13366 };
13367 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13368 .totals_by_language
13369 .iter()
13370 .filter(|l| l.test_count > 0)
13371 .collect();
13372 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13373 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13374 format!("[{}]", parts.join(","))
13375}
13376
13377async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13379 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13380 scope_map.insert(
13381 "__all__".to_string(),
13382 latest_run.map_or_else(
13383 || {
13384 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13385 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13386 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13387 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13388 "has_coverage":false,"submodules":{}})
13389 },
13390 build_test_scope_entry,
13391 ),
13392 );
13393 let all_roots: Vec<String> = {
13394 let reg = state.registry.lock().await;
13395 let mut seen = std::collections::BTreeSet::new();
13396 reg.entries
13397 .iter()
13398 .flat_map(|e| e.input_roots.iter().cloned())
13399 .filter(|r| seen.insert(r.clone()))
13400 .collect()
13401 };
13402 for root in &all_roots {
13403 let json_path = {
13404 let reg = state.registry.lock().await;
13405 reg.entries
13406 .iter()
13407 .find(|e| e.input_roots.iter().any(|r| r == root))
13408 .and_then(|e| e.json_path.clone())
13409 };
13410 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13411 let json_str = tokio::fs::read_to_string(&p).await.ok();
13412 json_str
13413 .as_deref()
13414 .and_then(|s| serde_json::from_str(s).ok())
13415 } else {
13416 None
13417 };
13418 if let Some(ref run) = run_for_root {
13419 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13420 }
13421 }
13422 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13423}
13424
13425#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13429 State(state): State<AppState>,
13430 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13431) -> Response {
13432 auto_scan_watched_dirs(&state).await;
13433 let watched_dirs_list: Vec<String> = {
13434 let wd = state.watched_dirs.lock().await;
13435 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13436 };
13437 let latest_run: Option<AnalysisRun> = {
13438 let json_path = {
13439 let reg = state.registry.lock().await;
13440 reg.entries.first().and_then(|e| e.json_path.clone())
13441 };
13442 if let Some(p) = json_path {
13443 let json_str = tokio::fs::read_to_string(&p).await.ok();
13444 json_str
13445 .as_deref()
13446 .and_then(|s| serde_json::from_str(s).ok())
13447 } else {
13448 None
13449 }
13450 };
13451
13452 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13454
13455 let cov_json: String = latest_run
13457 .as_ref()
13458 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13459 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13460
13461 let _cov_tier_json: String = latest_run
13463 .as_ref()
13464 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13465 .map_or_else(
13466 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13467 compute_cov_tier_json_str,
13468 );
13469
13470 let total_tests: u64 = latest_run
13471 .as_ref()
13472 .map_or(0, |r| r.summary_totals.test_count);
13473 let total_assertions: u64 = latest_run
13474 .as_ref()
13475 .map_or(0, |r| r.summary_totals.test_assertion_count);
13476 let total_suites: u64 = latest_run
13477 .as_ref()
13478 .map_or(0, |r| r.summary_totals.test_suite_count);
13479 let total_code: u64 = latest_run
13480 .as_ref()
13481 .map_or(0, |r| r.summary_totals.code_lines);
13482 let workspace_density: f64 = if total_code > 0 {
13483 total_tests as f64 / total_code as f64 * 1000.0
13484 } else {
13485 0.0
13486 };
13487 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13488 r.totals_by_language
13489 .iter()
13490 .filter(|l| l.test_count > 0)
13491 .count()
13492 });
13493 let most_tested: String = latest_run
13494 .as_ref()
13495 .and_then(|r| {
13496 r.totals_by_language
13497 .iter()
13498 .filter(|l| l.test_count > 0)
13499 .max_by_key(|l| l.test_count)
13500 })
13501 .map_or_else(
13502 || "\u{2014}".to_string(),
13503 |l| l.language.display_name().to_string(),
13504 );
13505 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13506 r.per_file_records
13507 .iter()
13508 .filter(|f| f.raw_line_categories.test_count > 0)
13509 .count() as u64
13510 });
13511 let total_files_analyzed: u64 = latest_run
13512 .as_ref()
13513 .map_or(0, |r| r.summary_totals.files_analyzed);
13514 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13515
13516 let cov_line_pct_str: String = latest_run
13518 .as_ref()
13519 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13520 .map_or_else(
13521 || "0".to_string(),
13522 |r| {
13523 format!(
13524 "{:.1}",
13525 r.summary_totals.coverage_lines_hit as f64
13526 / r.summary_totals.coverage_lines_found as f64
13527 * 100.0
13528 )
13529 },
13530 );
13531 let cov_fn_pct_str: String = latest_run
13532 .as_ref()
13533 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13534 .map_or_else(
13535 || "0".to_string(),
13536 |r| {
13537 format!(
13538 "{:.1}",
13539 r.summary_totals.coverage_functions_hit as f64
13540 / r.summary_totals.coverage_functions_found as f64
13541 * 100.0
13542 )
13543 },
13544 );
13545 let cov_branch_pct_str: String = latest_run
13546 .as_ref()
13547 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13548 .map_or_else(
13549 || "0".to_string(),
13550 |r| {
13551 format!(
13552 "{:.1}",
13553 r.summary_totals.coverage_branches_hit as f64
13554 / r.summary_totals.coverage_branches_found as f64
13555 * 100.0
13556 )
13557 },
13558 );
13559
13560 let cov_no_data_notice = if has_coverage {
13561 String::new()
13562 } else {
13563 String::from(
13564 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13565<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>
13566<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13567 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13568 <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>
13569 <span style="color:var(--muted);font-size:12px;">·</span>
13570 <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>
13571 <span style="color:var(--muted);font-size:12px;">·</span>
13572 <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>
13573 <span style="color:var(--muted);font-size:12px;">·</span>
13574 <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>
13575 <span style="color:var(--muted);font-size:12px;">·</span>
13576 <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>
13577</div>
13578<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13579</div>"#,
13580 )
13581 };
13582
13583 let workspace_density_str = format!("{workspace_density:.1}");
13584 let nonce = &csp_nonce;
13585 let toast_assets = sloc_toast_assets(nonce);
13586 let version = env!("CARGO_PKG_VERSION");
13587
13588 let watched_dirs_html: String = if state.server_mode {
13591 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()
13592 } else {
13593 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13594 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13595 .to_string()
13596 } else {
13597 watched_dirs_list
13598 .iter()
13599 .fold(String::new(), |mut s, d| {
13600 use std::fmt::Write as _;
13601 let escaped =
13602 d.replace('&', "&").replace('"', """).replace('<', "<");
13603 write!(
13604 s,
13605 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>"#
13606 ).expect("write to String is infallible");
13607 s
13608 })
13609 };
13610 format!(
13611 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>"#
13612 )
13613 };
13614
13615 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13617
13618 let html = format!(
13619 r#"<!doctype html>
13620<html lang="en">
13621<head>
13622 <meta charset="utf-8" />
13623 <meta name="viewport" content="width=device-width, initial-scale=1" />
13624 <title>OxideSLOC | Test Metrics</title>
13625 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13626 <style nonce="{nonce}">
13627 :root {{
13628 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13629 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13630 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13631 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13632 --info-bg:#eef3ff; --info-text:#4467d8;
13633 }}
13634 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13635 *{{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;}}
13636 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13637 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13638 .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;}}
13639 @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));}}}}
13640 .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);}}
13641 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13642 .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));}}
13643 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13644 .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;}}
13645 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13646 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13647 @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; }} }}
13648 .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;}}
13649 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13650 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13651 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13652 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13653 .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;}}
13654 .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;}}
13655 .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;}}
13656 .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;}}
13657 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13658 .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);}}
13659 .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;}}
13660 .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;}}
13661 .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;}}
13662 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13663 .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;}}
13664 .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);}}
13665 .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;}}
13666 .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;}}
13667 .tz-select:focus{{border-color:var(--oxide);}}
13668 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13669 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13670 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13671 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13672 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13673 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13674 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13675 .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);}}
13676 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13677 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13678 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13679 .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;}}
13680 .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;}}
13681 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13682 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13683 .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);}}
13684 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13685 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13686 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13687 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13688 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13689 .chart-canvas-wrap{{position:relative;height:280px;}}
13690 .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;}}
13691 .chart-no-data svg{{opacity:0.35;}}
13692 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13693 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13694 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13695 .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;}}
13696 .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;}}
13697 .data-table tr:last-child td{{border-bottom:none;}}
13698 .data-table tbody tr:hover td{{background:var(--surface-2);}}
13699 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13700 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13701 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13702 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13703 .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;}}
13704 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13705 .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);}}
13706 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13707 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13708 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13709 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13710 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13711 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13712 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13713 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13714 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13715 .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;}}
13716 .chart-select:focus{{border-color:var(--accent);}}
13717 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13718 .trend-canvas-wrap{{position:relative;height:260px;}}
13719 .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;}}
13720 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13721 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13722 .site-footer a{{color:var(--muted);}}
13723 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13724 .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;}}
13725 .btn:hover{{background:var(--surface-2);}}
13726 .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;}}
13727 .export-btn:hover{{background:var(--line);}}
13728 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13729 .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;}}
13730 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13731 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13732 .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;}}
13733 .scope-sel:focus{{border-color:var(--accent);}}
13734 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13735 .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;}}
13736 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13737 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13738 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13739 .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;}}
13740 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13741 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13742 .watched-chip-rm:hover{{color:var(--oxide);}}
13743 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13744 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13745 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13746 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13747 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13748 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13749 .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;}}
13750 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13751 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13752 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13753 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13754 .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;}}
13755 .cov-file-search:focus{{border-color:var(--accent);}}
13756 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13757 .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;}}
13758 body.dark-theme .cov-file-search{{background:var(--surface);}}
13759 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13760 .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;}}
13761 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13762 .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;}}
13763 .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);}}
13764 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13765 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13766 .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;}}
13767 .chart-modal-close:hover{{opacity:.7;}}
13768 body.dark-theme .chart-modal{{background:var(--surface);}}
13769 </style>
13770</head>
13771<body>
13772 <div class="background-watermarks" aria-hidden="true">
13773 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13774 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13775 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13776 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13777 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13778 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13779 </div>
13780 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13781 <div class="top-nav">
13782 <div class="top-nav-inner">
13783 <a class="brand" href="/">
13784 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13785 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13786 </a>
13787 <div class="nav-right">
13788 <a class="nav-pill" href="/">Home</a>
13789 <div class="nav-dropdown">
13790 <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>
13791 <div class="nav-dropdown-menu">
13792 <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>
13793 </div>
13794 </div>
13795 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13796 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13797 <div class="nav-dropdown">
13798 <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>
13799 <div class="nav-dropdown-menu">
13800 <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>
13801 </div>
13802 </div>
13803 <div class="server-status-wrap" id="server-status-wrap">
13804 <div class="nav-pill server-online-pill" id="server-status-pill">
13805 <span class="status-dot" id="status-dot"></span>
13806 <span id="server-status-label">Server</span>
13807 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13808 </div>
13809 <div class="server-status-tip">
13810 OxideSLOC is running — accessible on your network.
13811 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13812 </div>
13813 </div>
13814 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13815 <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>
13816 </button>
13817 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13818 <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>
13819 <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>
13820 </button>
13821 </div>
13822 </div>
13823 </div>
13824
13825 <div class="page">
13826 {watched_dirs_html}
13827 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
13828 <div class="scan-overlay-card">
13829 <div class="scan-spinner"></div>
13830 <div class="scan-overlay-text">Scanning folder…</div>
13831 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
13832 </div>
13833 </div>
13834 <style>
13835 .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);}}
13836 .scan-overlay.active{{display:flex;}}
13837 .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;}}
13838 .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;}}
13839 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
13840 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
13841 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
13842 </style>
13843 <div class="scope-bar">
13844 <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>
13845 <span class="scope-label">Scope</span>
13846 <div class="scope-sel-wrap">
13847 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13848 <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);">
13849 <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>
13850 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13851 </div>
13852 </div>
13853 </div>
13854 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13855 <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>
13856 <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>
13857 <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>
13858 <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>
13859 </div>
13860 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13861 <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>
13862 <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>
13863 <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>
13864 <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>
13865 </div>
13866
13867 <div class="panel" id="viz-panel">
13868 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13869 <span>Visualizations</span>
13870 <div style="display:flex;gap:8px;flex-wrap:wrap;">
13871 <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download test metrics as Excel workbook (.xlsx)"><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 Excel</button>
13872 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save charts as PNG image"><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> Export PNG</button>
13873 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export printable 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"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13874 </div>
13875 </div>
13876
13877 <div class="chart-box" style="margin-bottom:18px;">
13878 <div class="chart-box-header">
13879 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13880 <div style="display:flex;gap:8px;align-items:center;">
13881 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
13882 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13883 </div>
13884 </div>
13885 <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>
13886 <div class="trend-controls-bar">
13887 <label>Y Metric:
13888 <select class="chart-select" id="tm-trend-y">
13889 <option value="test_count" selected>Test Definitions</option>
13890 <option value="code_lines">Code Lines</option>
13891 </select>
13892 </label>
13893 <label>X Axis:
13894 <select class="chart-select" id="tm-trend-x">
13895 <option value="commit" selected>By Commit</option>
13896 <option value="time">By Time</option>
13897 </select>
13898 </label>
13899 <label id="tm-sub-label" style="display:none;">Submodule:
13900 <select class="chart-select" id="tm-trend-sub">
13901 <option value="">All (project total)</option>
13902 </select>
13903 </label>
13904 <label>Chart Size:
13905 <select class="chart-select" id="tm-trend-size">
13906 <option value="200">Compact</option>
13907 <option value="260" selected>Normal</option>
13908 <option value="360">Large</option>
13909 </select>
13910 </label>
13911 </div>
13912 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13913 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13914 </div>
13915
13916 <div class="chart-row">
13917 <div class="chart-box">
13918 <div class="chart-box-header">
13919 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13920 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13921 </div>
13922 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13923 <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>
13924 </div>
13925 <div class="chart-box">
13926 <div class="chart-box-header">
13927 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13928 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13929 </div>
13930 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13931 <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>
13932 </div>
13933 </div>
13934
13935 <div class="chart-row">
13936 <div class="chart-box">
13937 <div class="chart-box-header">
13938 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13939 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13940 </div>
13941 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13942 <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>
13943 </div>
13944 <div class="chart-box" id="suites-chart-box">
13945 <div class="chart-box-header">
13946 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13947 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13948 </div>
13949 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13950 <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>
13951 </div>
13952 </div>
13953
13954 <div class="chart-row">
13955 <div class="chart-box">
13956 <div class="chart-box-title">Test Files Breakdown</div>
13957 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13958 <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>
13959 </div>
13960 <div class="chart-box">
13961 <div class="chart-box-title">Test Composition</div>
13962 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13963 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13964 <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>
13965 </div>
13966 </div>
13967 </div>
13968
13969 <div class="panel">
13970 <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;">
13971 <h1 style="margin:0;">Test Metrics</h1>
13972 <div style="display:flex;gap:8px;flex-wrap:wrap;">
13973 <button type="button" class="export-btn" id="tm2-export-xlsx-btn" title="Download test metrics and LCOV coverage summary as an Excel workbook (.xlsx)"><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 Excel</button>
13974 <button type="button" class="export-btn" id="tm2-export-png-btn" title="Save the test metrics and coverage charts as a PNG image"><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> Export PNG</button>
13975 <button type="button" class="export-btn" id="tm2-export-pdf-btn" title="Export a printable PDF of test metrics with the LCOV coverage summary"><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> Export PDF</button>
13976 </div>
13977 </div>
13978 <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>
13979
13980 <div class="section-header">Language Breakdown</div>
13981 {cov_no_data_notice}
13982 <div style="overflow-x:auto;">
13983 <table class="data-table" id="lang-table">
13984 <thead><tr>
13985 <th>Language</th>
13986 <th class="num">Test Fns</th>
13987 <th class="num">Assertions</th>
13988 <th class="num">Suites</th>
13989 <th class="num">Code Lines</th>
13990 <th class="num">Files</th>
13991 <th class="num">Density / 1K</th>
13992 <th>Relative Density</th>
13993 </tr></thead>
13994 <tbody id="lang-tbody"></tbody>
13995 </table>
13996 </div>
13997 </div>
13998
13999 <div class="panel" id="cov-panel" style="display:none;">
14000 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14001 <div class="cov-gauge-row" id="cov-gauges">
14002 <div class="cov-gauge-card">
14003 <div class="cov-gauge-label">Line Coverage</div>
14004 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14005 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14006 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14007 <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>
14008 </div>
14009 <div class="cov-gauge-card">
14010 <div class="cov-gauge-label">Function Coverage</div>
14011 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14012 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14013 <div class="cov-gauge-sub">Functions hit / found</div>
14014 <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>
14015 </div>
14016 <div class="cov-gauge-card">
14017 <div class="cov-gauge-label">Branch Coverage</div>
14018 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14019 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14020 <div class="cov-gauge-sub">Branches hit / found</div>
14021 <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>
14022 </div>
14023 </div>
14024 <div class="chart-row">
14025 <div class="chart-box">
14026 <div class="chart-box-title">Line Coverage % by Language</div>
14027 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14028 </div>
14029 <div class="chart-box">
14030 <div class="chart-box-title">Coverage Tier Distribution</div>
14031 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14032 </div>
14033 </div>
14034
14035 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14036 <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>
14037 <div class="cov-file-toolbar">
14038 <div class="cov-filter-tabs" id="cov-filter-tabs">
14039 <button class="cov-tab active" data-tier="all">All</button>
14040 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14041 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14042 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14043 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14044 </div>
14045 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14046 </div>
14047 <div style="overflow-x:auto;">
14048 <table class="data-table" id="cov-file-table">
14049 <thead><tr>
14050 <th>File</th>
14051 <th>Lang</th>
14052 <th class="num">Line %</th>
14053 <th class="num">Lines Hit / Found</th>
14054 <th class="num">Fn %</th>
14055 <th class="num">Fns Hit / Found</th>
14056 </tr></thead>
14057 <tbody id="cov-file-tbody"></tbody>
14058 </table>
14059 </div>
14060 <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>
14061 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14062 </div>
14063
14064 </div>
14065
14066 <footer class="site-footer">
14067 local code analysis - metrics, history and reports
14068 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14069 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14070 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14071 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14072 · <a href="/api-docs" rel="noopener">REST API</a>
14073 </footer>
14074
14075 <script nonce="{nonce}">
14076 (function() {{
14077 // Theme
14078 var b = document.body;
14079 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14080 var tgl = document.getElementById('theme-toggle');
14081 if (tgl) tgl.addEventListener('click', function() {{
14082 var d = b.classList.toggle('dark-theme');
14083 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14084 }});
14085
14086 // Watermarks
14087 (function() {{
14088 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14089 if (!wms.length) return;
14090 var placed = [];
14091 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;}}
14092 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];}}
14093 var half=Math.floor(wms.length/2);
14094 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;}});
14095 }})();
14096
14097 // Code particles
14098 (function() {{
14099 var container = document.getElementById('code-particles');
14100 if (!container) return;
14101 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14102 for (var i = 0; i < 36; i++) {{
14103 (function(idx) {{
14104 var el = document.createElement('span');
14105 el.className = 'code-particle';
14106 el.textContent = snippets[idx % snippets.length];
14107 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14108 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14109 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14110 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';
14111 container.appendChild(el);
14112 }})(i);
14113 }}
14114 }})();
14115
14116 // Settings modal
14117 (function() {{
14118 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'}}];
14119 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);}});}}
14120 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14121 var btn=document.getElementById('settings-btn');if(!btn)return;
14122 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14123 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>';
14124 document.body.appendChild(m);
14125 var g=document.getElementById('scheme-grid');
14126 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);}});
14127 var cl=document.getElementById('settings-close');
14128 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');}});
14129 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14130 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14131 }})();
14132
14133 // Watched folder picker
14134 (function(){{
14135 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');}};
14136 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);
14137 }})();
14138 (function() {{
14139 var btn = document.getElementById('add-watched-btn');
14140 if (!btn) return;
14141 btn.addEventListener('click', function() {{
14142 fetch('/pick-directory?kind=reports')
14143 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14144 .then(function(data) {{
14145 if (!data.cancelled && data.selected_path) {{
14146 var form = document.createElement('form');
14147 form.method = 'POST';
14148 form.action = '/watched-dirs/add';
14149 var ri = document.createElement('input');
14150 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14151 var fi = document.createElement('input');
14152 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14153 form.appendChild(ri); form.appendChild(fi);
14154 document.body.appendChild(form);
14155 if (window.__scanOverlay) window.__scanOverlay();
14156 form.submit();
14157 }}
14158 }})
14159 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14160 }});
14161 }})();
14162 }})();
14163 </script>
14164
14165 <script src="/static/chart.js" nonce="{nonce}"></script>
14166 <script nonce="{nonce}">
14167 (function() {{
14168 var SCOPE_DATA = {scope_data_json};
14169 var currentRoot = '__all__';
14170 var currentSub = '';
14171 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14172 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14173 var ALL_CHARTS = [];
14174 var currentLangTests = [];
14175 var currentTrendPts = [];
14176
14177 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();}}
14178 function fmtFull(n){{return Number(n).toLocaleString();}}
14179 function isDark(){{return document.body.classList.contains('dark-theme');}}
14180 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14181 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14182 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14183
14184 function makeDlPlugin(fmtFn, anchor) {{
14185 return {{
14186 afterDatasetsDraw: function(chart) {{
14187 var ctx = chart.ctx;
14188 var tc = txtClr();
14189 chart.data.datasets.forEach(function(ds, di) {{
14190 var meta = chart.getDatasetMeta(di);
14191 meta.data.forEach(function(el, idx) {{
14192 var label = fmtFn(ds.data[idx], di, idx);
14193 if (label == null || label === '') return;
14194 ctx.save();
14195 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14196 ctx.fillStyle = tc;
14197 if (anchor === 'top') {{
14198 ctx.textAlign = 'center';
14199 ctx.textBaseline = 'bottom';
14200 ctx.fillText(String(label), el.x, el.y - 5);
14201 }} else {{
14202 ctx.textAlign = 'left';
14203 ctx.textBaseline = 'middle';
14204 ctx.fillText(String(label), el.x + 5, el.y);
14205 }}
14206 ctx.restore();
14207 }});
14208 }});
14209 }}
14210 }};
14211 }}
14212
14213 // Cursor: pointer over chart data, default over empty chart area.
14214 function chartCursor(e, els) {{
14215 var t = e.native && e.native.target;
14216 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14217 }}
14218 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14219
14220 // Plugin: draws % labels inside each doughnut slice.
14221 var donutPctPlugin = {{
14222 afterDatasetsDraw: function(chart) {{
14223 var ctx = chart.ctx;
14224 chart.data.datasets.forEach(function(ds, di) {{
14225 var meta = chart.getDatasetMeta(di);
14226 if (meta.hidden) return;
14227 var total = 0;
14228 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14229 if (!total) return;
14230 meta.data.forEach(function(arc, i) {{
14231 if (arc.hidden) return;
14232 var val = ds.data[i] || 0;
14233 var pct = val / total * 100;
14234 if (pct < 3) return;
14235 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14236 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14237 var tx = arc.x + midR * Math.cos(midAngle);
14238 var ty = arc.y + midR * Math.sin(midAngle);
14239 ctx.save();
14240 ctx.textAlign = 'center';
14241 ctx.textBaseline = 'middle';
14242 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14243 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14244 ctx.shadowBlur = 3;
14245 ctx.fillStyle = '#fff';
14246 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14247 ctx.restore();
14248 }});
14249 }});
14250 }}
14251 }};
14252
14253 function makeTmOverlay(title, subtitle, h) {{
14254 var overlay = document.createElement('div');
14255 overlay.className = 'chart-modal-overlay';
14256 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14257 var ch = Math.min(h || 560, maxH);
14258 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14259 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>';
14260 document.body.appendChild(overlay);
14261 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14262 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14263 return document.getElementById('tm-modal-canvas');
14264 }}
14265
14266 function getDataset() {{
14267 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14268 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14269 return r;
14270 }}
14271 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14272
14273 function showNoData(id, show) {{
14274 var el = document.getElementById(id);
14275 if (!el) return;
14276 var wrap = el.previousElementSibling;
14277 el.style.display = show ? '' : 'none';
14278 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14279 }}
14280
14281 function renderTestCharts(D) {{
14282 currentLangTests = D || [];
14283 testsChart = destroyChart(testsChart);
14284 densityChart = destroyChart(densityChart);
14285 if (!D || !D.length) {{
14286 showNoData('no-data-tests', true);
14287 showNoData('no-data-density', true);
14288 return;
14289 }}
14290 showNoData('no-data-tests', false);
14291 showNoData('no-data-density', false);
14292 var top15 = D.slice(0, 15);
14293 var canvas1 = document.getElementById('canvas-tests');
14294 if (canvas1) {{
14295 testsChart = new Chart(canvas1, {{
14296 type: 'bar',
14297 data: {{
14298 labels: top15.map(function(d){{ return d.lang; }}),
14299 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14300 }},
14301 options: {{
14302 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14303 layout: {{ padding: {{ right: 64 }} }},
14304 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14305 scales: {{
14306 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14307 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14308 }}
14309 }},
14310 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14311 }});
14312 ALL_CHARTS.push(testsChart);
14313 }}
14314 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14315 var canvas2 = document.getElementById('canvas-density');
14316 if (canvas2) {{
14317 densityChart = new Chart(canvas2, {{
14318 type: 'bar',
14319 data: {{
14320 labels: topD.map(function(d){{ return d.lang; }}),
14321 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 }}]
14322 }},
14323 options: {{
14324 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14325 layout: {{ padding: {{ right: 64 }} }},
14326 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14327 scales: {{
14328 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14329 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14330 }}
14331 }},
14332 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14333 }});
14334 ALL_CHARTS.push(densityChart);
14335 }}
14336 }}
14337
14338 function renderAssertionsChart(D) {{
14339 assertionsChart = destroyChart(assertionsChart);
14340 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14341 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14342 var canvas = document.getElementById('canvas-assertions');
14343 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14344 showNoData('no-data-assertions', false);
14345 assertionsChart = new Chart(canvas, {{
14346 type: 'bar',
14347 data: {{
14348 labels: top15.map(function(d){{ return d.lang; }}),
14349 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14350 }},
14351 options: {{
14352 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14353 layout: {{ padding: {{ right: 64 }} }},
14354 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14355 scales: {{
14356 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14357 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14358 }}
14359 }},
14360 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14361 }});
14362 ALL_CHARTS.push(assertionsChart);
14363 }}
14364
14365 function renderSuitesChart(D) {{
14366 suitesChart = destroyChart(suitesChart);
14367 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14368 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14369 var canvas = document.getElementById('canvas-suites');
14370 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14371 showNoData('no-data-suites', false);
14372 suitesChart = new Chart(canvas, {{
14373 type: 'bar',
14374 data: {{
14375 labels: top15.map(function(d){{ return d.lang; }}),
14376 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 }}]
14377 }},
14378 options: {{
14379 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14380 layout: {{ padding: {{ right: 64 }} }},
14381 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14382 scales: {{
14383 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14384 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14385 }}
14386 }},
14387 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14388 }});
14389 ALL_CHARTS.push(suitesChart);
14390 }}
14391
14392 function renderFilesChart(totals) {{
14393 filesChart = destroyChart(filesChart);
14394 var canvas = document.getElementById('canvas-files');
14395 if (!canvas) return;
14396 var testF = totals.test_files || 0;
14397 var totalF = totals.total_files || 0;
14398 var nonTest = Math.max(0, totalF - testF);
14399 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14400 showNoData('no-data-files', false);
14401 var dark = isDark();
14402 filesChart = new Chart(canvas, {{
14403 type: 'doughnut',
14404 data: {{
14405 labels: ['Test Files', 'Non-Test Files'],
14406 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14407 }},
14408 options: {{
14409 responsive: true, maintainAspectRatio: false, cutout: '62%',
14410 onHover: chartCursor,
14411 plugins: {{
14412 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14413 generateLabels: function(chart) {{
14414 var ds = chart.data.datasets[0];
14415 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14416 return chart.data.labels.map(function(lbl, i) {{
14417 var val = ds.data[i] || 0;
14418 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14419 return {{
14420 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14421 fillStyle: ds.backgroundColor[i],
14422 strokeStyle: ds.borderColor,
14423 lineWidth: ds.borderWidth,
14424 hidden: false,
14425 index: i,
14426 datasetIndex: 0
14427 }};
14428 }});
14429 }}
14430 }},
14431 onHover: function(e, item, leg) {{
14432 var ch = leg.chart;
14433 var t = e.native && e.native.target;
14434 if (t) t.style.cursor = 'pointer';
14435 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14436 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14437 ch.update();
14438 }},
14439 onLeave: function(e, item, leg) {{
14440 var ch = leg.chart;
14441 var t = e.native && e.native.target;
14442 if (t) t.style.cursor = 'default';
14443 ch.setActiveElements([]);
14444 ch.tooltip.setActiveElements([], {{}});
14445 ch.update('none');
14446 }}
14447 }},
14448 tooltip: {{ callbacks: {{ label: function(ctx) {{
14449 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14450 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14451 }} }} }}
14452 }}
14453 }},
14454 plugins: [donutPctPlugin]
14455 }});
14456 ALL_CHARTS.push(filesChart);
14457 }}
14458
14459 function renderCompositionChart(totals) {{
14460 compositionChart = destroyChart(compositionChart);
14461 var canvas = document.getElementById('canvas-composition');
14462 if (!canvas) return;
14463 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14464 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14465 showNoData('no-data-composition', false);
14466 compositionChart = new Chart(canvas, {{
14467 type: 'bar',
14468 data: {{
14469 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14470 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14471 }},
14472 options: {{
14473 responsive: true, maintainAspectRatio: false,
14474 layout: {{ padding: {{ top: 22 }} }},
14475 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14476 scales: {{
14477 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14478 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14479 }}
14480 }},
14481 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14482 }});
14483 ALL_CHARTS.push(compositionChart);
14484 }}
14485
14486 function renderCovCharts(covD, tiers) {{
14487 covChart = destroyChart(covChart);
14488 tierChart = destroyChart(tierChart);
14489 var covCanvas = document.getElementById('canvas-cov');
14490 if (covCanvas && covD && covD.length) {{
14491 covChart = new Chart(covCanvas, {{
14492 type: 'bar',
14493 data: {{
14494 labels: covD.map(function(d){{ return d.lang; }}),
14495 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 }}]
14496 }},
14497 options: {{
14498 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14499 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14500 scales: {{
14501 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14502 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14503 }}
14504 }}
14505 }});
14506 ALL_CHARTS.push(covChart);
14507 }}
14508 var tierCanvas = document.getElementById('canvas-cov-tiers');
14509 if (tierCanvas && tiers) {{
14510 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14511 tierChart = new Chart(tierCanvas, {{
14512 type: 'doughnut',
14513 data: {{
14514 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14515 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14516 }},
14517 options: {{
14518 responsive: true, maintainAspectRatio: false, cutout: '62%',
14519 onHover: chartCursor,
14520 plugins: {{
14521 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14522 onHover: function(e, item, leg) {{
14523 var ch = leg.chart;
14524 var t = e.native && e.native.target;
14525 if (t) t.style.cursor = 'pointer';
14526 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14527 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14528 ch.update();
14529 }},
14530 onLeave: function(e, item, leg) {{
14531 var ch = leg.chart;
14532 var t = e.native && e.native.target;
14533 if (t) t.style.cursor = 'default';
14534 ch.setActiveElements([]);
14535 ch.tooltip.setActiveElements([], {{}});
14536 ch.update('none');
14537 }}
14538 }},
14539 tooltip: {{ callbacks: {{ label: function(ctx) {{
14540 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14541 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14542 }} }} }}
14543 }}
14544 }},
14545 plugins: [donutPctPlugin]
14546 }});
14547 ALL_CHARTS.push(tierChart);
14548 }}
14549 }}
14550
14551 function buildLangTable(D) {{
14552 var tbody = document.getElementById('lang-tbody');
14553 if (!tbody) return;
14554 if (!D || !D.length) {{
14555 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>';
14556 return;
14557 }}
14558 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14559 tbody.innerHTML = D.map(function(d) {{
14560 var barW = Math.round(d.density / maxDensity * 120);
14561 return '<tr>' +
14562 '<td><strong>' + d.lang + '</strong></td>' +
14563 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14564 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14565 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14566 '<td class="num">' + fmtFull(d.code) + '</td>' +
14567 '<td class="num">' + fmtFull(d.files) + '</td>' +
14568 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14569 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14570 '</tr>';
14571 }}).join('');
14572 }}
14573
14574 var covFileData = [];
14575 var covFileTier = 'all';
14576 var covFileSearch = '';
14577
14578 function pctBadge(pct) {{
14579 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14580 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14581 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14582 }}
14583
14584 function buildCovFileTable() {{
14585 var tbody = document.getElementById('cov-file-tbody');
14586 var empty = document.getElementById('cov-file-empty');
14587 var count = document.getElementById('cov-file-count');
14588 if (!tbody) return;
14589 var srch = covFileSearch.toLowerCase();
14590 var filtered = covFileData.filter(function(f) {{
14591 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14592 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14593 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14594 if (covFileTier === 'high' && f.line_pct < 80) return false;
14595 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14596 return true;
14597 }});
14598 if (!filtered.length) {{
14599 tbody.innerHTML = '';
14600 if (empty) empty.style.display = '';
14601 if (count) count.textContent = '';
14602 return;
14603 }}
14604 if (empty) empty.style.display = 'none';
14605 var shown = Math.min(filtered.length, 500);
14606 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14607 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14608 var fnCol = f.fn_pct < 0
14609 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14610 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14611 return '<tr>' +
14612 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
14613 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14614 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14615 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14616 fnCol +
14617 '</tr>';
14618 }}).join('');
14619 }}
14620
14621 (function() {{
14622 var tabs = document.getElementById('cov-filter-tabs');
14623 if (tabs) {{
14624 tabs.addEventListener('click', function(e) {{
14625 var btn = e.target.closest('.cov-tab');
14626 if (!btn) return;
14627 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14628 btn.classList.add('active');
14629 covFileTier = btn.getAttribute('data-tier');
14630 buildCovFileTable();
14631 }});
14632 }}
14633 var srch = document.getElementById('cov-file-search');
14634 if (srch) {{
14635 srch.addEventListener('input', function() {{
14636 covFileSearch = this.value;
14637 buildCovFileTable();
14638 }});
14639 }}
14640 }})();
14641
14642 function updateCovGauges(t) {{
14643 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14644 var el;
14645 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14646 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14647 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14648 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14649 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14650 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14651 }}
14652
14653 function applyScope() {{
14654 var d = getDataset();
14655 var t = d.totals;
14656 var el;
14657 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14658 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14659 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14660 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14661 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14662 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14663 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14664 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14665 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14666 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14667 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14668 renderTestCharts(d.lang_tests);
14669 renderAssertionsChart(d.lang_tests);
14670 renderSuitesChart(d.lang_tests);
14671 renderFilesChart(t);
14672 renderCompositionChart(t);
14673 buildLangTable(d.lang_tests);
14674 var covPanel = document.getElementById('cov-panel');
14675 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14676 if (d.has_coverage) {{
14677 renderCovCharts(d.cov, d.cov_tiers);
14678 updateCovGauges(t);
14679 covFileData = d.file_cov || [];
14680 covFileTier = 'all';
14681 covFileSearch = '';
14682 var tabs = document.getElementById('cov-filter-tabs');
14683 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14684 var srch = document.getElementById('cov-file-search');
14685 if (srch) srch.value = '';
14686 buildCovFileTable();
14687 }}
14688 loadTrend();
14689 }}
14690
14691 // Populate scope-root-sel from SCOPE_DATA keys
14692 (function() {{
14693 var sel = document.getElementById('scope-root-sel');
14694 if (!sel) return;
14695 Object.keys(SCOPE_DATA).forEach(function(k) {{
14696 if (k === '__all__') return;
14697 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14698 }});
14699 }})();
14700
14701 document.getElementById('scope-root-sel').addEventListener('change', function() {{
14702 currentRoot = this.value;
14703 currentSub = '';
14704 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14705 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14706 var subWrap = document.getElementById('scope-sub-wrap');
14707 var subSel = document.getElementById('scope-sub-sel');
14708 subSel.innerHTML = '<option value="">Entire project</option>';
14709 if (subNames.length) {{
14710 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14711 subWrap.style.display = 'flex';
14712 }} else {{
14713 subWrap.style.display = 'none';
14714 }}
14715 applyScope();
14716 }});
14717
14718 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14719 currentSub = this.value;
14720 applyScope();
14721 }});
14722
14723 var allTrendData = [];
14724
14725 var TM_Y_META = {{
14726 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14727 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
14728 }};
14729
14730 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14731 function hexRgb(hex) {{
14732 var h = String(hex).replace('#', '');
14733 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14734 var n = parseInt(h, 16);
14735 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14736 }}
14737 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14738 // tint at the top to transparent at the bottom (no flat solid block).
14739 function tmTrendGradient(ctx2, chartArea, color) {{
14740 var rgb = hexRgb(color);
14741 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14742 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
14743 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14744 g.addColorStop(1, 'rgba(' + rgb + ',0)');
14745 return g;
14746 }}
14747
14748 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14749 // so linear interpolation between adjacent points matches the drawn line).
14750 function tmLineYAt(chart, px) {{
14751 var meta = chart.getDatasetMeta(0);
14752 if (!meta || !meta.data || !meta.data.length) return null;
14753 var d = meta.data;
14754 if (px <= d[0].x) return d[0].y;
14755 for (var i = 1; i < d.length; i++) {{
14756 if (px <= d[i].x) {{
14757 var span = d[i].x - d[i - 1].x;
14758 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14759 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14760 }}
14761 }}
14762 return d[d.length - 1].y;
14763 }}
14764
14765 // Plugin: only show the tooltip / finger cursor when the pointer is over the
14766 // gradient fill (inside the plot and at/below the line) — never in the empty
14767 // space above the line. Outside the fill we retype the event as 'mouseout' so
14768 // the core interaction dismisses any active tooltip on its own.
14769 var tmFillGuard = {{
14770 id: 'tmFillGuard',
14771 beforeEvent: function(chart, args) {{
14772 var e = args.event;
14773 if (!e || e.type !== 'mousemove') return;
14774 var ca = chart.chartArea;
14775 if (!ca) return;
14776 var inFill = false;
14777 if (e.x >= ca.left && e.x <= ca.right) {{
14778 var ly = tmLineYAt(chart, e.x);
14779 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14780 }}
14781 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14782 if (!inFill) {{ e.type = 'mouseout'; }}
14783 }}
14784 }};
14785
14786 // Single source of truth for the test-metrics trend chart config so the inline
14787 // chart and the Full View modal render identically (straight segments, gradient
14788 // fill, white-ringed points, gradient-only interactivity).
14789 function buildTmTrendConfig(pts, ctrl, meta) {{
14790 return {{
14791 type: 'line',
14792 data: {{
14793 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14794 datasets: [{{
14795 label: meta.label,
14796 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14797 borderColor: meta.color,
14798 borderWidth: 2.5,
14799 backgroundColor: function(context) {{
14800 var ca = context.chart.chartArea;
14801 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14802 return tmTrendGradient(context.chart.ctx, ca, meta.color);
14803 }},
14804 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14805 pointBorderColor: '#fff',
14806 pointBorderWidth: 2,
14807 pointRadius: 6,
14808 pointHoverRadius: 9,
14809 pointHoverBorderWidth: 2.5,
14810 fill: true, tension: 0
14811 }}]
14812 }},
14813 options: {{
14814 responsive: true, maintainAspectRatio: false,
14815 layout: {{ padding: {{ top: 22 }} }},
14816 interaction: {{ mode: 'index', intersect: false }},
14817 plugins: {{
14818 legend: {{ display: false }},
14819 tooltip: {{
14820 mode: 'index', intersect: false,
14821 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14822 }}
14823 }},
14824 scales: {{
14825 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14826 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14827 }}
14828 }},
14829 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14830 }};
14831 }}
14832
14833 function getTrendControls() {{
14834 var ySel = document.getElementById('tm-trend-y');
14835 var xSel = document.getElementById('tm-trend-x');
14836 var sizeSel = document.getElementById('tm-trend-size');
14837 var subSel = document.getElementById('tm-trend-sub');
14838 return {{
14839 yKey: ySel ? ySel.value : 'test_count',
14840 xMode: xSel ? xSel.value : 'commit',
14841 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
14842 submod: subSel ? subSel.value : ''
14843 }};
14844 }}
14845
14846 function makeTrendLabel(d, xMode) {{
14847 if (xMode === 'commit') {{
14848 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14849 }}
14850 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14851 }}
14852
14853 function buildTrend(data) {{
14854 allTrendData = data || [];
14855 renderTrend();
14856 }}
14857
14858 function renderTrend() {{
14859 var data = allTrendData;
14860 var ctrl = getTrendControls();
14861 var trendCanvas = document.getElementById('canvas-trend');
14862 var trendWrap = document.getElementById('trend-canvas-wrap');
14863 var trendEmpty = document.getElementById('trend-empty');
14864
14865 // Apply chart size
14866 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14867
14868 // Filter by submodule if selected (entries from project_label match)
14869 var pts = data.slice().reverse();
14870 if (ctrl.submod) {{
14871 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14872 }}
14873
14874 currentTrendPts = pts;
14875
14876 if (!pts.length) {{
14877 if (trendCanvas) trendCanvas.style.display = 'none';
14878 if (trendEmpty) trendEmpty.style.display = '';
14879 return;
14880 }}
14881 if (trendCanvas) trendCanvas.style.display = '';
14882 if (trendEmpty) trendEmpty.style.display = 'none';
14883
14884 trendChart = destroyChart(trendChart);
14885 if (!trendCanvas) return;
14886
14887 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14888
14889 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14890 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14891 ALL_CHARTS.push(trendChart);
14892
14893 // Populate submodule selector from unique project_labels
14894 var subSel = document.getElementById('tm-trend-sub');
14895 var subLabel = document.getElementById('tm-sub-label');
14896 if (subSel && data.length) {{
14897 var projects = [];
14898 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14899 if (projects.length > 1) {{
14900 var curVal = subSel.value;
14901 subSel.innerHTML = '<option value="">All (project total)</option>';
14902 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14903 if (subLabel) subLabel.style.display = '';
14904 }} else {{
14905 if (subLabel) subLabel.style.display = 'none';
14906 }}
14907 }}
14908 }}
14909
14910 // ── Full View expand buttons ──────────────────────────────────────────────
14911 (function() {{
14912 var btn = document.getElementById('tests-expand-btn');
14913 if (!btn) return;
14914 btn.addEventListener('click', function() {{
14915 var D = currentLangTests;
14916 if (!D || !D.length) return;
14917 var top15 = D.slice(0, 15);
14918 var h = Math.max(320, top15.length * 36 + 80);
14919 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14920 if (!canvas) return;
14921 new Chart(canvas, {{
14922 type: 'bar',
14923 data: {{
14924 labels: top15.map(function(d){{ return d.lang; }}),
14925 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14926 }},
14927 options: {{
14928 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14929 layout: {{ padding: {{ right: 72 }} }},
14930 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14931 scales: {{
14932 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14933 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14934 }}
14935 }},
14936 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14937 }});
14938 }});
14939 }})();
14940
14941 (function() {{
14942 var btn = document.getElementById('density-expand-btn');
14943 if (!btn) return;
14944 btn.addEventListener('click', function() {{
14945 var D = currentLangTests;
14946 if (!D || !D.length) return;
14947 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14948 var h = Math.max(320, topD.length * 36 + 80);
14949 var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14950 if (!canvas) return;
14951 new Chart(canvas, {{
14952 type: 'bar',
14953 data: {{
14954 labels: topD.map(function(d){{ return d.lang; }}),
14955 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 }}]
14956 }},
14957 options: {{
14958 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14959 layout: {{ padding: {{ right: 72 }} }},
14960 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14961 scales: {{
14962 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14963 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14964 }}
14965 }},
14966 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14967 }});
14968 }});
14969 }})();
14970
14971 (function() {{
14972 var btn = document.getElementById('trend-expand-btn');
14973 if (!btn) return;
14974 btn.addEventListener('click', function() {{
14975 var pts = currentTrendPts;
14976 if (!pts || !pts.length) return;
14977 var ctrl = getTrendControls();
14978 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14979 var title = meta.label + ' Trend \u2014 Full View';
14980 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14981 if (!canvas) return;
14982 // Reuse the exact inline-chart config so Full View matches the default view
14983 // (straight segments + gradient-only interactivity), just larger.
14984 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14985 }});
14986 }})();
14987
14988 (function() {{
14989 var btn = document.getElementById('assertions-expand-btn');
14990 if (!btn) return;
14991 btn.addEventListener('click', function() {{
14992 var D = currentLangTests;
14993 if (!D || !D.length) return;
14994 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14995 if (!top15.length) return;
14996 var h = Math.max(320, top15.length * 36 + 80);
14997 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14998 if (!canvas) return;
14999 new Chart(canvas, {{
15000 type: 'bar',
15001 data: {{
15002 labels: top15.map(function(d){{ return d.lang; }}),
15003 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15004 }},
15005 options: {{
15006 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
15007 layout: {{ padding: {{ right: 72 }} }},
15008 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15009 scales: {{
15010 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15011 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15012 }}
15013 }},
15014 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15015 }});
15016 }});
15017 }})();
15018
15019 (function() {{
15020 var btn = document.getElementById('suites-expand-btn');
15021 if (!btn) return;
15022 btn.addEventListener('click', function() {{
15023 var D = currentLangTests;
15024 if (!D || !D.length) return;
15025 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15026 if (!top15.length) return;
15027 var h = Math.max(320, top15.length * 36 + 80);
15028 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15029 if (!canvas) return;
15030 new Chart(canvas, {{
15031 type: 'bar',
15032 data: {{
15033 labels: top15.map(function(d){{ return d.lang; }}),
15034 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 }}]
15035 }},
15036 options: {{
15037 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
15038 layout: {{ padding: {{ right: 72 }} }},
15039 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15040 scales: {{
15041 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15042 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15043 }}
15044 }},
15045 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15046 }});
15047 }});
15048 }})();
15049
15050 // Wire trend control selectors — re-render without re-fetching
15051 (function() {{
15052 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15053 var el = document.getElementById(id);
15054 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15055 }});
15056 }})();
15057
15058 function loadTrend() {{
15059 var url = '/api/metrics/history?limit=100';
15060 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15061 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15062 buildTrend(data);
15063 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15064 var btn = document.getElementById('multi-compare-trend-btn');
15065 if (btn) {{
15066 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15067 if (ids.length >= 2) {{
15068 btn.style.display = '';
15069 btn.onclick = function() {{
15070 // Reverse so oldest first (API returns newest first).
15071 var sorted = ids.slice().reverse();
15072 if (sorted.length === 2) {{
15073 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15074 }} else {{
15075 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15076 }}
15077 }};
15078 }} else {{
15079 btn.style.display = 'none';
15080 }}
15081 }}
15082 }}).catch(function(){{
15083 var trendEmpty = document.getElementById('trend-empty');
15084 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15085 }});
15086 }}
15087
15088 // Re-render charts on theme toggle
15089 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15090 setTimeout(function() {{
15091 ALL_CHARTS.forEach(function(c) {{
15092 if (c && c.options && c.options.scales) {{
15093 Object.values(c.options.scales).forEach(function(ax) {{
15094 if (ax.grid) ax.grid.color = clr();
15095 if (ax.ticks) ax.ticks.color = txtClr();
15096 }});
15097 c.update();
15098 }}
15099 }});
15100 }}, 80);
15101 }});
15102
15103 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15104 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15105 function tmExportMeta() {{
15106 var sel = document.getElementById('scope-sel');
15107 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15108 if (!proj || proj === '__all__') proj = 'All projects';
15109 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15110 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15111 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15112 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15113 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15114 }}
15115
15116 function exportTmXLSX() {{
15117 var D = currentLangTests;
15118 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15119 var t = tmExportMeta();
15120 function s2b(s) {{ return new TextEncoder().encode(s); }}
15121 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15122 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; }}
15123 function crc32(d) {{
15124 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;}}}}
15125 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15126 }}
15127 // Store all cells as strings so Excel left-aligns uniformly.
15128 function cs(addr, val, bold) {{
15129 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15130 }}
15131 // Build an Excel Table XML definition for a given sheet range and columns.
15132 function makeTableXml(tblId, name, ref, cols) {{
15133 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15134 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15135 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15136 x+='<autoFilter ref="'+ref+'"/>';
15137 x+='<tableColumns count="'+cols.length+'">';
15138 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15139 x+='</tableColumns>';
15140 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15141 return x+'</table>';
15142 }}
15143 // Worksheet XML with optional Excel Table part reference.
15144 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15145 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15146 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15147 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15148 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15149 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15150 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>';}});
15151 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>';}}
15152 x+='</sheetData>';
15153 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15154 return x+'</worksheet>';
15155 }}
15156
15157 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15158 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15159 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15160 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15161 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15162 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15163
15164 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15165 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15166 var sheets=[];
15167
15168 // Sheet: Summary
15169 var sumHdr=['Metric','Value'];
15170 var sumRows=[
15171 ['Project / Scope', t.proj],
15172 ['Export Date', t.full],
15173 ['Test Functions', Number(totTests).toLocaleString()],
15174 ['Assertions', Number(totAssert).toLocaleString()],
15175 ['Test Suites', Number(totSuites).toLocaleString()],
15176 ['Languages with Tests', String(D.length)],
15177 ['Total Code Lines', Number(totCode).toLocaleString()],
15178 ['Average Density (per 1K)', String(avgDensity)],
15179 ];
15180 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15181
15182 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15183 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15184 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)];}});
15185 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15186 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15187
15188 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15189 var covDs=(typeof getDataset==='function')?getDataset():null;
15190 if(covDs&&covDs.has_coverage){{
15191 var covT=covDs.totals||{{}};
15192 var covSumHdr=['Metric','Value'];
15193 var covSumRows=[
15194 ['Line Coverage', (covT.cov_line||'0')+'%'],
15195 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15196 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15197 ];
15198 if(covDs.cov_tiers){{
15199 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15200 covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15201 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15202 }}
15203 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15204
15205 if(covDs.cov&&covDs.cov.length){{
15206 var covLangHdr=['Language','Line Coverage %'];
15207 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15208 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15209 }}
15210 if(covFileData&&covFileData.length){{
15211 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15212 var covFileRows=covFileData.map(function(f){{
15213 var noFn=f.fn_pct<0;
15214 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)];
15215 }});
15216 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15217 }}
15218 }}
15219
15220 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>';
15221 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>';
15222
15223 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15224 var files=[];
15225 var ctOverrides='', wbSheetTags='', wbRelTags='';
15226 sheets.forEach(function(sh,i){{
15227 var n=i+1;
15228 var lastCol=col2l(sh.hdr.length);
15229 var ref='A1:'+lastCol+(sh.rows.length+1);
15230 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15231 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15232 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>';
15233 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15234 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15235 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15236 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15237 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15238 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15239 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15240 }});
15241 var styleRid='rId'+(sheets.length+1);
15242 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>';
15243 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>';
15244 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>';
15245 files.unshift(
15246 {{name:'[Content_Types].xml',data:s2b(ct)}},
15247 {{name:'_rels/.rels',data:s2b(dotrels)}},
15248 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15249 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15250 {{name:'xl/styles.xml',data:s2b(styl)}}
15251 );
15252 var parts=[],offsets=[],total=0;
15253 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;}});
15254 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;}});
15255 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));
15256 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;}});
15257 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15258 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15259 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15260 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15261 }}
15262
15263 function exportTmPNG() {{
15264 // Map canvas IDs to display titles
15265 var CHART_TITLES = {{
15266 'canvas-trend': 'TEST COUNT TREND',
15267 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15268 'canvas-density': 'TEST DENSITY (PER 1,000 CODE LINES)',
15269 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15270 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15271 'canvas-files': 'TEST FILES BREAKDOWN',
15272 'canvas-composition': 'TEST COMPOSITION',
15273 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15274 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15275 }};
15276 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15277 var covPanelEl=document.getElementById('cov-panel');
15278 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15279 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15280 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15281 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15282 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15283 var t=tmExportMeta();
15284 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15285 var trendCanvas=document.getElementById('canvas-trend');
15286 var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15287 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15288 var TOTAL_W=COLW*2+GAP;
15289 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15290 TREND_H=Math.min(Math.max(200,TREND_H),340);
15291 // Per-row chart heights (2-col grid)
15292 var gridRows=Math.ceil(gridCanvases.length/2);
15293 var rowHeights=[];
15294 for(var ri=0;ri<gridRows;ri++){{
15295 var rh=240;
15296 for(var ci=0;ci<2;ci++){{
15297 var cv=gridCanvases[ri*2+ci];
15298 if(cv&&cv.width>0){{
15299 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15300 rh=Math.max(rh,Math.min(420,nat));
15301 }}
15302 }}
15303 rowHeights.push(rh);
15304 }}
15305 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15306 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15307 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15308 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15309 var ctx=out.getContext('2d');
15310 var cs2=getComputedStyle(document.body);
15311 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15312 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15313 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15314
15315 // Background
15316 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15317
15318 // Orange header block
15319 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15320 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15321 ctx.fillText('Test Metrics — '+t.proj,22,42);
15322 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15323 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15324 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15325
15326 // Helper: draw a section title label
15327 function drawTitle(label, x, y, w) {{
15328 ctx.save();
15329 ctx.fillStyle=oxide;
15330 ctx.font='700 11px '+TM_FONT;
15331 ctx.textBaseline='middle';
15332 ctx.textAlign='left';
15333 ctx.letterSpacing='0.07em';
15334 ctx.fillText(label, x+2, y+TITLE_H/2);
15335 // Underline
15336 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15337 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15338 ctx.globalAlpha=1;
15339 ctx.restore();
15340 }}
15341
15342 var yOff=HEADER_H;
15343
15344 // Trend chart (full width)
15345 if(hasTrend){{
15346 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15347 yOff+=TITLE_H;
15348 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15349 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15350 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15351 ctx.drawImage(surf,0,yOff);
15352 yOff+=TREND_H+ROW_PAD;
15353 }}
15354
15355 // Grid charts (2-col), each cell gets title + chart
15356 for(var gi=0;gi<gridRows;gi++){{
15357 var rh2=rowHeights[gi];
15358 // Draw row titles and charts
15359 for(var gci=0;gci<2;gci++){{
15360 var idx2=gi*2+gci;
15361 if(idx2>=gridCanvases.length)continue;
15362 var gcv=gridCanvases[idx2];
15363 var gx=gci*(COLW+GAP);
15364 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15365 }}
15366 yOff+=TITLE_H;
15367 for(var gci2=0;gci2<2;gci2++){{
15368 var idx3=gi*2+gci2;
15369 if(idx3>=gridCanvases.length)continue;
15370 var gcv2=gridCanvases[idx3];
15371 var gx2=gci2*(COLW+GAP);
15372 var natW=gcv2.width,natH=gcv2.height;
15373 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15374 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15375 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15376 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15377 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15378 ctx.drawImage(surf2,gx2,yOff);
15379 }}
15380 yOff+=rh2+ROW_PAD;
15381 }}
15382
15383 // Dark footer
15384 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15385 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15386 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15387
15388 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15389 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15390 }}
15391
15392 function exportTmPDF(ev) {{
15393 var D=currentLangTests;
15394 var t=tmExportMeta();
15395 var strips=document.querySelectorAll('.summary-strip');
15396 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15397 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15398 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15399 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15400 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15401 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15402 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15403 var rows='';
15404 (D||[]).forEach(function(d){{
15405 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15406 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15407 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15408 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15409 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15410 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15411 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15412 }});
15413 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15414 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15415 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15416 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15417 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15418 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15419 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15420 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>';
15421 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15422 +'html,body{{height:100%;margin:0;}}'
15423 +'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;}}'
15424 +'.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;}}'
15425 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15426 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15427 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15428 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15429 +'.rep-body{{padding:20px 32px;flex:1;}}'
15430 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15431 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15432 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15433 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15434 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15435 +'.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;}}'
15436 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15437 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15438 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15439 +'.n{{text-align:right;}}'
15440 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15441 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15442 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15443 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15444 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15445 +'.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;}}'
15446 +'</style>';
15447 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15448 var covDs=(typeof getDataset==='function')?getDataset():null;
15449 var covHtml='';
15450 if(covDs&&covDs.has_coverage){{
15451 var covT=covDs.totals||{{}};
15452 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15453 +'<div class="cov-strip">'
15454 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15455 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15456 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15457 +'</div>';
15458 if(covFileData&&covFileData.length){{
15459 var cfrows='';
15460 covFileData.forEach(function(f){{
15461 var noFn=f.fn_pct<0;
15462 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15463 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15464 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15465 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15466 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15467 }});
15468 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15469 +'<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>';
15470 }}
15471 }}
15472 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15473 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15474 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15475 +'<div class="rep-body">'+statsHtml
15476 +'<div class="section-hdr">Language Breakdown</div>'
15477 +tableHtml+covHtml+'</div>'
15478 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15479 +'</body></html>';
15480 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15481 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15482 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15483 }}
15484
15485 (function() {{
15486 var xBtn=document.getElementById('tm-export-xlsx-btn');
15487 var pngBtn=document.getElementById('tm-export-png-btn');
15488 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15489 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15490 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15491 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15492 // Test Metrics panel export controls — share the same handlers, which now
15493 // fold in the LCOV Coverage Summary when the current scope has coverage.
15494 var xBtn2=document.getElementById('tm2-export-xlsx-btn');
15495 var pngBtn2=document.getElementById('tm2-export-png-btn');
15496 var pdfBtn2=document.getElementById('tm2-export-pdf-btn');
15497 if(xBtn2)xBtn2.addEventListener('click',exportTmXLSX);
15498 if(pngBtn2)pngBtn2.addEventListener('click',exportTmPNG);
15499 if(pdfBtn2)pdfBtn2.addEventListener('click',exportTmPDF);
15500 }})();
15501
15502 applyScope();
15503 }})();
15504 </script>
15505 <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>
15506 {toast_assets}
15507</body>
15508</html>"#,
15509 );
15510 (
15511 [(axum::http::header::CACHE_CONTROL, "no-store")],
15512 Html(html),
15513 )
15514 .into_response()
15515}
15516
15517#[derive(Deserialize)]
15524struct EmbedQuery {
15525 run_id: Option<String>,
15526 theme: Option<String>,
15527}
15528
15529async fn embed_handler(
15530 State(state): State<AppState>,
15531 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15532 Query(query): Query<EmbedQuery>,
15533) -> Response {
15534 let entry = {
15535 let reg = state.registry.lock().await;
15536 query.run_id.as_ref().map_or_else(
15537 || reg.entries.first().cloned(),
15538 |id| reg.find_by_run_id(id).cloned(),
15539 )
15540 };
15541
15542 let Some(entry) = entry else {
15543 return Html(
15544 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15545 .to_string(),
15546 )
15547 .into_response();
15548 };
15549
15550 let dark = query.theme.as_deref() == Some("dark");
15551 let languages: Vec<(String, u64, u64)> = entry
15552 .json_path
15553 .as_ref()
15554 .and_then(|p| read_json(p).ok())
15555 .map(|run| {
15556 run.totals_by_language
15557 .iter()
15558 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15559 .collect()
15560 })
15561 .unwrap_or_default();
15562
15563 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15564}
15565
15566fn render_embed_widget(
15567 entry: &RegistryEntry,
15568 languages: &[(String, u64, u64)],
15569 dark: bool,
15570 csp_nonce: &str,
15571) -> String {
15572 let s = &entry.summary;
15573 let total = s.code_lines + s.comment_lines + s.blank_lines;
15574 let code_pct = s
15575 .code_lines
15576 .checked_mul(100)
15577 .and_then(|n| n.checked_div(total))
15578 .unwrap_or(0);
15579
15580 let (bg, fg, surface, muted, border) = if dark {
15581 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15582 } else {
15583 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15584 };
15585
15586 let mut lang_rows = String::new();
15587 for (name, files, code) in languages {
15588 write!(
15589 lang_rows,
15590 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15591 escape_html(name),
15592 format_number(*files),
15593 format_number(*code),
15594 )
15595 .ok();
15596 }
15597
15598 let lang_table = if lang_rows.is_empty() {
15599 String::new()
15600 } else {
15601 format!(
15602 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15603 )
15604 };
15605
15606 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15607 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15608 let project_esc = escape_html(&entry.project_label);
15609 let code_lines = format_number(s.code_lines);
15610 let comment_lines = format_number(s.comment_lines);
15611 let files = format_number(s.files_analyzed);
15612 let code_raw = s.code_lines;
15613 let comment_raw = s.comment_lines;
15614 let blank_raw = s.blank_lines;
15615
15616 format!(
15617 r#"<!doctype html>
15618<html lang="en">
15619<head>
15620 <meta charset="utf-8">
15621 <meta name="viewport" content="width=device-width,initial-scale=1">
15622 <title>OxideSLOC — {project_esc}</title>
15623 <script src="/static/chart.js"></script>
15624 <style nonce="{csp_nonce}">
15625 *{{box-sizing:border-box;margin:0;padding:0}}
15626 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15627 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15628 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15629 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15630 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15631 .card .v{{font-size:18px;font-weight:700}}
15632 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15633 .row{{display:flex;gap:12px;align-items:flex-start}}
15634 .pie{{width:120px;height:120px;flex-shrink:0}}
15635 .lt{{border-collapse:collapse;width:100%;flex:1}}
15636 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15637 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15638 .n{{text-align:right}}
15639 .footer{{margin-top:10px;color:{muted};font-size:10px}}
15640 </style>
15641</head>
15642<body>
15643 <h2>{project_esc}</h2>
15644 <div class="sub">{timestamp} · run {run_short}</div>
15645 <div class="cards">
15646 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15647 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15648 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15649 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15650 </div>
15651 <div class="row">
15652 <canvas class="pie" id="c"></canvas>
15653 {lang_table}
15654 </div>
15655 <div class="footer">oxide-sloc</div>
15656 <script nonce="{csp_nonce}">
15657 new Chart(document.getElementById('c'),{{
15658 type:'doughnut',
15659 data:{{
15660 labels:['Code','Comments','Blank'],
15661 datasets:[{{
15662 data:[{code_raw},{comment_raw},{blank_raw}],
15663 backgroundColor:['#4a78ee','#b35428','#aaa'],
15664 borderWidth:0
15665 }}]
15666 }},
15667 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15668 }});
15669 </script>
15670</body>
15671</html>"#
15672 )
15673}
15674
15675fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15680 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15681 OnceLock::new();
15682 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15683 let mut guard = map
15684 .lock()
15685 .unwrap_or_else(std::sync::PoisonError::into_inner);
15686 guard
15687 .entry(dir.to_path_buf())
15688 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15689 .clone()
15690}
15691
15692#[allow(clippy::too_many_lines)]
15693fn persist_run_artifacts(
15694 run: &sloc_core::AnalysisRun,
15695 report_html: &str,
15696 run_dir: &Path,
15697 report_title: &str,
15698 file_stem: &str,
15699 result_context: RunResultContext,
15700) -> Result<(RunArtifacts, PendingPdf)> {
15701 let dir_lock = output_dir_lock(run_dir);
15704 let _dir_guard = dir_lock
15705 .lock()
15706 .unwrap_or_else(std::sync::PoisonError::into_inner);
15707
15708 let html_dir = run_dir.join("html");
15710 let pdf_dir = run_dir.join("pdf");
15711 let excel_dir = run_dir.join("excel");
15712 let json_dir = run_dir.join("json");
15713 let submodules_dir = run_dir.join("submodules");
15714 for dir in &[
15715 run_dir,
15716 &html_dir,
15717 &pdf_dir,
15718 &excel_dir,
15719 &json_dir,
15720 &submodules_dir,
15721 ] {
15722 fs::create_dir_all(dir)
15723 .with_context(|| format!("failed to create directory {}", dir.display()))?;
15724 }
15725
15726 let html_path = {
15728 let path = html_dir.join(format!("report_{file_stem}.html"));
15729 fs::write(&path, report_html)
15730 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15731 Some(path)
15732 };
15733
15734 let json_path = {
15736 let path = json_dir.join(format!("result_{file_stem}.json"));
15737 let json = serde_json::to_string_pretty(run)
15738 .context("failed to serialize analysis run to JSON")?;
15739 fs::write(&path, json)
15740 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15741 Some(path)
15742 };
15743
15744 let (pdf_path, pending_pdf) = {
15746 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15747 match write_pdf_from_run(run, &pdf_dest) {
15748 Ok(()) => {
15749 eprintln!(
15750 "[oxide-sloc][pdf] native PDF written to {}",
15751 pdf_dest.display()
15752 );
15753 (Some(pdf_dest), None)
15754 }
15755 Err(native_err) => {
15756 eprintln!(
15757 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15758 );
15759 let source_html_path = html_path
15760 .as_ref()
15761 .expect("html_path always Some here")
15762 .clone();
15763 let pending = Some((source_html_path, pdf_dest.clone(), false));
15764 (Some(pdf_dest), pending)
15765 }
15766 }
15767 };
15768
15769 let csv_path = {
15771 let path = excel_dir.join(format!("report_{file_stem}.csv"));
15772 if let Err(e) = sloc_report::write_csv(run, &path) {
15773 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15774 None
15775 } else {
15776 Some(path)
15777 }
15778 };
15779
15780 let xlsx_path = {
15781 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15782 if let Err(e) = sloc_report::write_xlsx(run, &path) {
15783 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15784 None
15785 } else {
15786 Some(path)
15787 }
15788 };
15789
15790 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15792
15793 if run.effective_configuration.discovery.submodule_breakdown {
15795 let run_id = &run.tool.run_id;
15796 for s in &run.submodule_summaries {
15797 build_submodule_row(s, run, run_id, run_dir);
15798 }
15799 }
15800
15801 generate_offline_index(
15803 run,
15804 run_dir,
15805 file_stem,
15806 html_path.as_deref(),
15807 pdf_path.as_deref(),
15808 json_path.as_deref(),
15809 scan_config_path.as_deref(),
15810 &result_context,
15811 );
15812
15813 Ok((
15814 RunArtifacts {
15815 output_dir: run_dir.to_path_buf(),
15816 html_path,
15817 pdf_path,
15818 json_path,
15819 csv_path,
15820 xlsx_path,
15821 scan_config_path,
15822 report_title: report_title.to_string(),
15823 result_context,
15824 },
15825 pending_pdf,
15826 ))
15827}
15828
15829#[allow(clippy::too_many_arguments)]
15832#[allow(clippy::too_many_lines)]
15833#[allow(clippy::similar_names)]
15834fn generate_offline_index(
15835 run: &sloc_core::AnalysisRun,
15836 run_dir: &Path,
15837 file_stem: &str,
15838 html_path: Option<&Path>,
15839 pdf_path: Option<&Path>,
15840 json_path: Option<&Path>,
15841 scan_config_path: Option<&Path>,
15842 result_context: &RunResultContext,
15843) {
15844 let prev_entry = &result_context.prev_entry;
15845 let prev_scan_count = result_context.prev_scan_count;
15846 let project_path = &result_context.project_path;
15847
15848 let scan_delta = prev_entry.as_ref().and_then(|prev| {
15849 prev.json_path
15850 .as_ref()
15851 .and_then(|p| read_json(p).ok())
15852 .map(|prev_run| compute_delta(&prev_run, run))
15853 });
15854
15855 let files_analyzed = run.per_file_records.len() as u64;
15856 let files_skipped = run.skipped_file_records.len() as u64;
15857 let totals = sum_lang_totals(run);
15858
15859 let DeltaFields {
15860 prev_fa_str,
15861 prev_fs_str,
15862 prev_pl_str,
15863 prev_cl_str,
15864 prev_cml_str,
15865 prev_bl_str,
15866 delta_fa_str,
15867 delta_fa_class,
15868 delta_fs_str,
15869 delta_fs_class,
15870 delta_pl_str,
15871 delta_pl_class,
15872 delta_cl_str,
15873 delta_cl_class,
15874 delta_cml_str,
15875 delta_cml_class,
15876 delta_bl_str,
15877 delta_bl_class,
15878 delta_lines_added,
15879 delta_lines_removed,
15880 delta_lines_net_str,
15881 delta_lines_net_class,
15882 } = compute_delta_fields(
15883 prev_entry.as_ref(),
15884 &totals,
15885 files_analyzed,
15886 files_skipped,
15887 scan_delta.as_ref(),
15888 );
15889
15890 let git_commit_url = git_commit_url_for(run);
15891 let git_branch_url = git_branch_url_for(run);
15892 let scan_performed_by = scan_performed_by(run);
15893
15894 let make_rel = |p: Option<&Path>| -> Option<String> {
15896 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15897 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15898 };
15899
15900 let run_id = &run.tool.run_id;
15901
15902 let submodule_rows: Vec<SubmoduleRow> = run
15904 .submodule_summaries
15905 .iter()
15906 .map(|s| {
15907 let safe = sanitize_project_label(&s.name);
15908 let key = format!("sub_{safe}");
15909 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15910 SubmoduleRow {
15911 name: s.name.clone(),
15912 relative_path: s.relative_path.clone(),
15913 files_analyzed: s.files_analyzed,
15914 code_lines: s.code_lines,
15915 comment_lines: s.comment_lines,
15916 blank_lines: s.blank_lines,
15917 total_physical_lines: s.total_physical_lines,
15918 html_url: if sub_path.exists() {
15919 Some(format!("submodules/{key}.html"))
15920 } else {
15921 None
15922 },
15923 }
15924 })
15925 .collect();
15926
15927 let lang_chart_json = build_lang_chart_json(run);
15928
15929 let scan_config_rel =
15930 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15931
15932 let template = ResultTemplate {
15933 version: env!("CARGO_PKG_VERSION"),
15934 report_title: run.effective_configuration.reporting.report_title.clone(),
15935 project_path: project_path.clone(),
15936 output_dir: display_path(run_dir),
15937 run_id: run_id.clone(),
15938 run_id_short: run_id
15939 .split('-')
15940 .next_back()
15941 .unwrap_or(run_id)
15942 .chars()
15943 .take(7)
15944 .collect(),
15945 files_analyzed,
15946 files_skipped,
15947 physical_lines: totals.physical_lines,
15948 code_lines: totals.code_lines,
15949 comment_lines: totals.comment_lines,
15950 blank_lines: totals.blank_lines,
15951 mixed_lines: totals.mixed_lines,
15952 functions: totals.functions,
15953 classes: totals.classes,
15954 variables: totals.variables,
15955 imports: totals.imports,
15956 html_url: make_rel(html_path),
15957 pdf_url: make_rel(pdf_path),
15958 json_url: make_rel(json_path),
15959 html_download_url: make_rel(html_path),
15960 pdf_download_url: make_rel(pdf_path),
15961 json_download_url: make_rel(json_path),
15962 html_path: html_path.map(display_path),
15963 json_path: json_path.map(display_path),
15964 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15965 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15966 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15967 prev_fa_str,
15968 prev_fs_str,
15969 prev_pl_str,
15970 prev_cl_str,
15971 prev_cml_str,
15972 prev_bl_str,
15973 delta_fa_str,
15974 delta_fa_class,
15975 delta_fs_str,
15976 delta_fs_class,
15977 delta_pl_str,
15978 delta_pl_class,
15979 delta_cl_str,
15980 delta_cl_class,
15981 delta_cml_str,
15982 delta_cml_class,
15983 delta_bl_str,
15984 delta_bl_class,
15985 delta_lines_added,
15986 delta_lines_removed,
15987 delta_lines_net_str,
15988 delta_lines_net_class,
15989 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15990 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15991 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15992 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15993 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15994 git_branch: run.git_branch.clone(),
15995 git_branch_url,
15996 git_commit: run.git_commit_short.clone(),
15997 git_commit_long: run.git_commit_long.clone(),
15998 git_author: run.git_commit_author.clone(),
15999 git_commit_url,
16000 scan_performed_by,
16001 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16002 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16003 os_display: format!(
16004 "{} / {}",
16005 run.environment.operating_system, run.environment.architecture
16006 ),
16007 test_count: run.summary_totals.test_count,
16008 test_assertion_count: run.summary_totals.test_assertion_count,
16009 current_scan_number: prev_scan_count + 1,
16010 prev_scan_count,
16011 submodule_rows,
16012 pdf_generating: false,
16013 scan_config_url: scan_config_rel,
16014 lang_chart_json,
16015 scatter_chart_json: build_scatter_chart_json(run),
16016 semantic_chart_json: build_semantic_chart_json(run),
16017 submodule_chart_json: build_submodule_chart_json(run),
16018 has_submodule_data: !run.submodule_summaries.is_empty(),
16019 has_semantic_data: run
16020 .totals_by_language
16021 .iter()
16022 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16023 csp_nonce: String::new(),
16024 confluence_configured: false,
16025 server_mode: false,
16026 report_header_footer: run
16027 .effective_configuration
16028 .reporting
16029 .report_header_footer
16030 .clone(),
16031 is_offline: true,
16032 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16033 lsloc: run.summary_totals.lsloc,
16034 uloc: run.uloc,
16035 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16036 duplicate_group_count: run.duplicate_groups.len(),
16037 has_cocomo: run.cocomo.is_some(),
16038 cocomo_effort_str: run
16039 .cocomo
16040 .as_ref()
16041 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16042 cocomo_duration_str: run
16043 .cocomo
16044 .as_ref()
16045 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16046 cocomo_staff_str: run
16047 .cocomo
16048 .as_ref()
16049 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16050 cocomo_ksloc_str: run
16051 .cocomo
16052 .as_ref()
16053 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16054 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16055 || "Organic".to_string(),
16056 |c| cocomo_mode_label(c.mode).to_string(),
16057 ),
16058 cocomo_mode_tooltip: run
16059 .cocomo
16060 .as_ref()
16061 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16062 complexity_alert: 0,
16063 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16064 cov_line_pct: cov_pct_str(
16065 run.summary_totals.coverage_lines_hit,
16066 run.summary_totals.coverage_lines_found,
16067 ),
16068 cov_fn_pct: cov_pct_str(
16069 run.summary_totals.coverage_functions_hit,
16070 run.summary_totals.coverage_functions_found,
16071 ),
16072 cov_branch_pct: cov_pct_str(
16073 run.summary_totals.coverage_branches_hit,
16074 run.summary_totals.coverage_branches_found,
16075 ),
16076 cov_lines_summary: cov_lines_summary_str(
16077 run.summary_totals.coverage_lines_hit,
16078 run.summary_totals.coverage_lines_found,
16079 ),
16080 };
16081
16082 if let Ok(html) = template.render() {
16083 let html = inline_offline_logos(&html);
16087 let index_path = run_dir.join("index.html");
16088 if let Err(e) = fs::write(&index_path, html) {
16089 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16090 }
16091 }
16092}
16093
16094fn inline_offline_logos(html: &str) -> String {
16098 use base64::Engine;
16099 let text_uri = format!(
16100 "data:image/png;base64,{}",
16101 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16102 );
16103 let small_uri = format!(
16104 "data:image/png;base64,{}",
16105 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16106 );
16107 html.replace("/images/logo/logo-text.png", &text_uri)
16108 .replace("/images/logo/small-logo.png", &small_uri)
16109}
16110
16111fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16114 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16116 return Some(found);
16117 }
16118 find_scan_config_in_dir_flat(dir)
16120}
16121
16122fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16123 let exact = dir.join("scan-config.json");
16124 if exact.exists() {
16125 return Some(exact);
16126 }
16127 fs::read_dir(dir).ok().and_then(|entries| {
16128 entries
16129 .filter_map(std::result::Result::ok)
16130 .find(|e| {
16131 let name = e.file_name();
16132 let name = name.to_string_lossy();
16133 name.starts_with("scan-config") && name.ends_with(".json")
16134 })
16135 .map(|e| e.path())
16136 })
16137}
16138
16139#[derive(Deserialize)]
16144struct ExportPdfRequest {
16145 html: String,
16146 #[serde(default)]
16147 filename: Option<String>,
16148}
16149
16150async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16151 let html_content = body.html;
16152 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16153 if html_content.is_empty() {
16154 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16155 }
16156 let tmp_dir = std::env::temp_dir();
16158 let html_path = tmp_dir.join(format!(
16159 "sloc-export-{}.html",
16160 uuid::Uuid::new_v4().simple()
16161 ));
16162 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16163 if let Err(e) = std::fs::write(&html_path, &html_content) {
16164 return (
16165 StatusCode::INTERNAL_SERVER_ERROR,
16166 format!("Failed to write temp HTML: {e}"),
16167 )
16168 .into_response();
16169 }
16170 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16171 let _ = std::fs::remove_file(&html_path);
16172 if let Err(e) = pdf_result {
16173 let _ = std::fs::remove_file(&pdf_path);
16174 return (
16175 StatusCode::INTERNAL_SERVER_ERROR,
16176 format!("PDF generation failed: {e}"),
16177 )
16178 .into_response();
16179 }
16180 let pdf_bytes = match std::fs::read(&pdf_path) {
16181 Ok(b) => b,
16182 Err(e) => {
16183 let _ = std::fs::remove_file(&pdf_path);
16184 return (
16185 StatusCode::INTERNAL_SERVER_ERROR,
16186 format!("Failed to read PDF: {e}"),
16187 )
16188 .into_response();
16189 }
16190 };
16191 let _ = std::fs::remove_file(&pdf_path);
16192 let safe_name: String = filename
16193 .chars()
16194 .map(|c| {
16195 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16196 c
16197 } else {
16198 '_'
16199 }
16200 })
16201 .collect();
16202 let disposition = format!("attachment; filename=\"{safe_name}\"");
16203 (
16204 [
16205 (header::CONTENT_TYPE, "application/pdf".to_string()),
16206 (header::CONTENT_DISPOSITION, disposition),
16207 ],
16208 pdf_bytes,
16209 )
16210 .into_response()
16211}
16212
16213async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16214 let toml_str = match toml::to_string_pretty(&state.base_config) {
16215 Ok(s) => s,
16216 Err(e) => {
16217 return (
16218 StatusCode::INTERNAL_SERVER_ERROR,
16219 format!("serialization error: {e}"),
16220 )
16221 .into_response();
16222 }
16223 };
16224 (
16225 [
16226 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16227 (
16228 header::CONTENT_DISPOSITION,
16229 "attachment; filename=\".oxide-sloc.toml\"",
16230 ),
16231 ],
16232 toml_str,
16233 )
16234 .into_response()
16235}
16236
16237#[derive(Serialize)]
16238struct OkResponse {
16239 ok: bool,
16240}
16241
16242#[derive(Serialize)]
16243struct SaveProfileResponse {
16244 ok: bool,
16245 id: String,
16246}
16247
16248#[derive(Serialize)]
16249struct ProfileListResponse {
16250 profiles: Vec<ScanProfile>,
16251}
16252
16253#[derive(Serialize)]
16254struct ImportConfigResponse {
16255 ok: bool,
16256 config: sloc_config::AppConfig,
16257}
16258
16259#[derive(Deserialize)]
16260struct ImportConfigBody {
16261 toml: String,
16262}
16263
16264async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16265 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16266 Ok(config) => {
16267 if let Err(e) = config.validate() {
16268 return error::unprocessable_entity(&e.to_string());
16269 }
16270 Json(ImportConfigResponse { ok: true, config }).into_response()
16271 }
16272 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16273 }
16274}
16275
16276async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16279 let store = state.scan_profiles.lock().await;
16280 Json(ProfileListResponse {
16281 profiles: store.profiles.clone(),
16282 })
16283}
16284
16285#[derive(Deserialize)]
16286struct SaveScanProfileBody {
16287 name: String,
16288 params: serde_json::Value,
16289}
16290
16291async fn api_save_scan_profile(
16292 State(state): State<AppState>,
16293 Json(body): Json<SaveScanProfileBody>,
16294) -> impl IntoResponse {
16295 if body.name.trim().is_empty() {
16296 return error::bad_request("name must not be empty");
16297 }
16298
16299 let id = uuid::Uuid::new_v4().to_string();
16300 let profile = ScanProfile {
16301 id: id.clone(),
16302 name: body.name.trim().to_string(),
16303 created_at: chrono::Utc::now().to_rfc3339(),
16304 params: body.params,
16305 };
16306
16307 let mut store = state.scan_profiles.lock().await;
16308 store.profiles.push(profile);
16309 if let Err(e) = store.save(&state.scan_profiles_path) {
16310 tracing::warn!("failed to persist scan profiles: {e}");
16311 }
16312 drop(store);
16313
16314 (
16315 StatusCode::CREATED,
16316 Json(SaveProfileResponse { ok: true, id }),
16317 )
16318 .into_response()
16319}
16320
16321async fn api_delete_scan_profile(
16322 State(state): State<AppState>,
16323 AxumPath(id): AxumPath<String>,
16324) -> impl IntoResponse {
16325 let mut store = state.scan_profiles.lock().await;
16326 let before = store.profiles.len();
16327 store.profiles.retain(|p| p.id != id);
16328 if store.profiles.len() == before {
16329 drop(store);
16330 return error::not_found("profile not found");
16331 }
16332 if let Err(e) = store.save(&state.scan_profiles_path) {
16333 tracing::warn!("failed to persist scan profiles: {e}");
16334 }
16335 drop(store);
16336 Json(OkResponse { ok: true }).into_response()
16337}
16338
16339fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16340 let value = raw.unwrap_or("out/web").trim();
16341 let path = if value.is_empty() {
16342 PathBuf::from("out/web")
16343 } else {
16344 PathBuf::from(value)
16345 };
16346
16347 if path.is_absolute() {
16348 path
16349 } else {
16350 workspace_root().join(path)
16351 }
16352}
16353
16354fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16356 std::env::var("SLOC_GIT_CLONES_DIR")
16357 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16358}
16359
16360pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16363 let safe: String = repo_url
16364 .chars()
16365 .map(|c| {
16366 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16367 c
16368 } else {
16369 '_'
16370 }
16371 })
16372 .take(80)
16373 .collect();
16374 clones_dir.join(safe)
16375}
16376
16377pub(crate) fn scan_path_to_artifacts(
16380 scan_path: &Path,
16381 base_config: &AppConfig,
16382 label: &str,
16383) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16384 let mut config = base_config.clone();
16385 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16386 label.clone_into(&mut config.reporting.report_title);
16387 let run = analyze(&config, "git", None, None)?;
16388 let html = render_html(&run)?;
16389 let run_id = run.tool.run_id.clone();
16390 let project_label = sanitize_project_label(label);
16391 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16392 let file_stem = {
16393 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16394 if commit.is_empty() {
16395 project_label
16396 } else {
16397 format!("{project_label}_{commit}")
16398 }
16399 };
16400 let (artifacts, _pending_pdf) = persist_run_artifacts(
16401 &run,
16402 &html,
16403 &output_dir,
16404 label,
16405 &file_stem,
16406 RunResultContext::default(),
16407 )?;
16408 Ok((run_id, artifacts, run))
16409}
16410
16411async fn restart_poll_schedules(state: &AppState) {
16413 let store = state.schedules.lock().await;
16414 let poll_schedules: Vec<_> = store
16415 .schedules
16416 .iter()
16417 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16418 .cloned()
16419 .collect();
16420 drop(store);
16421 for schedule in poll_schedules {
16422 let interval = schedule.interval_secs.unwrap_or(300);
16423 let st = state.clone();
16424 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16425 }
16426}
16427
16428async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16434 if state.tls_enabled {
16435 return;
16436 }
16437 let store = state.schedules.lock().await;
16438 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16439 s.kind == sloc_git::ScanScheduleKind::Webhook
16440 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16441 });
16442 drop(store);
16443 if has_gitlab_webhook {
16444 tracing::warn!(
16445 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16446 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16447 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16448 proxy so the token is not exposed in cleartext."
16449 );
16450 }
16451}
16452
16453fn split_patterns(raw: Option<&str>) -> Vec<String> {
16454 raw.unwrap_or("")
16455 .lines()
16456 .flat_map(|line| line.split(','))
16457 .map(str::trim)
16458 .filter(|part| !part.is_empty())
16459 .map(ToOwned::to_owned)
16460 .collect()
16461}
16462
16463#[must_use]
16464pub fn build_sub_run(
16465 parent: &AnalysisRun,
16466 sub: &sloc_core::SubmoduleSummary,
16467 parent_path: &str,
16468) -> AnalysisRun {
16469 let sub_files: Vec<_> = parent
16470 .per_file_records
16471 .iter()
16472 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16473 .cloned()
16474 .collect();
16475 let mut config = parent.effective_configuration.clone();
16476 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16477
16478 let mut functions = 0u64;
16480 let mut classes = 0u64;
16481 let mut variables = 0u64;
16482 let mut imports = 0u64;
16483 let mut test_count = 0u64;
16484 let mut test_assertion_count = 0u64;
16485 let mut test_suite_count = 0u64;
16486 let mut mixed_lines_separate = 0u64;
16487 let mut coverage_lines_found = 0u64;
16488 let mut coverage_lines_hit = 0u64;
16489 let mut coverage_functions_found = 0u64;
16490 let mut coverage_functions_hit = 0u64;
16491 let mut coverage_branches_found = 0u64;
16492 let mut coverage_branches_hit = 0u64;
16493 for r in &sub_files {
16494 functions += r.raw_line_categories.functions;
16495 classes += r.raw_line_categories.classes;
16496 variables += r.raw_line_categories.variables;
16497 imports += r.raw_line_categories.imports;
16498 test_count += r.raw_line_categories.test_count;
16499 test_assertion_count += r.raw_line_categories.test_assertion_count;
16500 test_suite_count += r.raw_line_categories.test_suite_count;
16501 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16502 if let Some(cov) = &r.coverage {
16503 coverage_lines_found += u64::from(cov.lines_found);
16504 coverage_lines_hit += u64::from(cov.lines_hit);
16505 coverage_functions_found += u64::from(cov.functions_found);
16506 coverage_functions_hit += u64::from(cov.functions_hit);
16507 coverage_branches_found += u64::from(cov.branches_found);
16508 coverage_branches_hit += u64::from(cov.branches_hit);
16509 }
16510 }
16511
16512 AnalysisRun {
16513 tool: parent.tool.clone(),
16514 environment: parent.environment.clone(),
16515 effective_configuration: config,
16516 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16517 summary_totals: SummaryTotals {
16518 files_considered: sub.files_analyzed,
16519 files_analyzed: sub.files_analyzed,
16520 files_skipped: 0,
16521 total_physical_lines: sub.total_physical_lines,
16522 code_lines: sub.code_lines,
16523 comment_lines: sub.comment_lines,
16524 blank_lines: sub.blank_lines,
16525 mixed_lines_separate,
16526 functions,
16527 classes,
16528 variables,
16529 imports,
16530 test_count,
16531 test_assertion_count,
16532 test_suite_count,
16533 coverage_lines_found,
16534 coverage_lines_hit,
16535 coverage_functions_found,
16536 coverage_functions_hit,
16537 coverage_branches_found,
16538 coverage_branches_hit,
16539 cyclomatic_complexity: 0,
16540 lsloc: None,
16541 ..Default::default()
16542 },
16543 totals_by_language: sub.language_summaries.clone(),
16544 per_file_records: sub_files,
16545 skipped_file_records: vec![],
16546 warnings: vec![],
16547 submodule_summaries: vec![],
16548 git_commit_short: sub.git_commit_short.clone(),
16549 git_commit_long: sub.git_commit_long.clone(),
16550 git_branch: sub.git_branch.clone(),
16551 git_commit_author: sub.git_commit_author.clone(),
16552 git_commit_date: sub.git_commit_date.clone(),
16553 git_tags: None,
16554 git_nearest_tag: None,
16555 git_remote_url: sub.git_remote_url.clone(),
16556 style_summary: None,
16557 cocomo: None,
16558 uloc: 0,
16559 dryness_pct: None,
16560 duplicate_groups: vec![],
16561 duplicates_excluded: 0,
16562 }
16563}
16564
16565#[must_use]
16566pub fn sanitize_project_label(raw: &str) -> String {
16567 let candidate = raw
16570 .split(['/', '\\'])
16571 .rfind(|s| !s.is_empty())
16572 .unwrap_or("project");
16573
16574 let mut value = String::with_capacity(candidate.len());
16575 for ch in candidate.chars() {
16576 if ch.is_ascii_alphanumeric() {
16577 value.push(ch.to_ascii_lowercase());
16578 } else {
16579 value.push('-');
16580 }
16581 }
16582
16583 let compact = value.trim_matches('-').to_string();
16584 if compact.is_empty() {
16585 "project".to_string()
16586 } else {
16587 compact
16588 }
16589}
16590
16591fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16594 let s = path.to_string_lossy();
16595 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16596 return PathBuf::from(format!(r"\\{rest}"));
16597 }
16598 if let Some(rest) = s.strip_prefix(r"\\?\") {
16599 return PathBuf::from(rest);
16600 }
16601 path
16602}
16603
16604fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16607 let base = if let Some(rest) = remote.strip_prefix("git@") {
16608 let (host, path) = rest.split_once(':')?;
16609 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16610 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16611 remote
16612 .trim_end_matches('/')
16613 .trim_end_matches(".git")
16614 .to_owned()
16615 } else {
16616 return None;
16617 };
16618 let base = base.trim_end_matches('/');
16619 if base.contains("gitlab.com") || base.contains("gitlab.") {
16621 Some(format!("{base}/-/commit/{sha}"))
16622 } else if base.contains("bitbucket.org") {
16623 Some(format!("{base}/commits/{sha}"))
16624 } else {
16625 Some(format!("{base}/commit/{sha}"))
16626 }
16627}
16628
16629fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16632 let base = if let Some(rest) = remote.strip_prefix("git@") {
16633 let (host, path) = rest.split_once(':')?;
16634 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16635 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16636 remote
16637 .trim_end_matches('/')
16638 .trim_end_matches(".git")
16639 .to_owned()
16640 } else {
16641 return None;
16642 };
16643 let base = base.trim_end_matches('/');
16644 if base.contains("gitlab.com") || base.contains("gitlab.") {
16645 Some(format!("{base}/-/tree/{branch}"))
16646 } else {
16647 Some(format!("{base}/tree/{branch}"))
16648 }
16649}
16650
16651fn display_path(path: &Path) -> String {
16652 let s = path.to_string_lossy();
16653 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16658 return format!(r"\\{rest}");
16659 }
16660 if let Some(rest) = s.strip_prefix(r"\\?\") {
16661 return rest.to_owned();
16662 }
16663 s.into_owned()
16664}
16665
16666fn sanitize_path_str(s: &str) -> String {
16667 if let Some(rest) = s.strip_prefix("//?/UNC/") {
16671 return format!("//{rest}");
16672 }
16673 if let Some(rest) = s.strip_prefix("//?/") {
16674 return rest.to_owned();
16675 }
16676 display_path(Path::new(s))
16677}
16678
16679fn workspace_root() -> PathBuf {
16680 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16682 let p = PathBuf::from(root);
16683 if p.is_dir() {
16684 return p;
16685 }
16686 }
16687
16688 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16691}
16692
16693fn make_git_label(repo: &str, ref_name: &str) -> String {
16695 if repo.is_empty() || ref_name.is_empty() {
16696 return String::new();
16697 }
16698 let base = repo
16699 .trim_end_matches('/')
16700 .trim_end_matches(".git")
16701 .rsplit('/')
16702 .next()
16703 .unwrap_or("repo");
16704 let ref_safe: String = ref_name
16705 .chars()
16706 .map(|c| {
16707 if c.is_alphanumeric() || c == '-' || c == '.' {
16708 c
16709 } else {
16710 '_'
16711 }
16712 })
16713 .collect();
16714 format!("{base}_at_{ref_safe}_sloc")
16715}
16716
16717fn desktop_dir() -> PathBuf {
16719 if let Ok(profile) = std::env::var("USERPROFILE") {
16720 let p = PathBuf::from(profile).join("Desktop");
16721 if p.exists() {
16722 return p;
16723 }
16724 }
16725 if let Ok(home) = std::env::var("HOME") {
16726 let p = PathBuf::from(home).join("Desktop");
16727 if p.exists() {
16728 return p;
16729 }
16730 }
16731 workspace_root().join("out").join("web")
16732}
16733
16734fn resolve_input_path(raw: &str) -> PathBuf {
16735 let trimmed = raw.trim();
16736 if trimmed.is_empty() {
16737 return workspace_root().join("samples").join("basic");
16738 }
16739
16740 let candidate = PathBuf::from(trimmed);
16741 let resolved = if candidate.is_absolute() {
16742 candidate
16743 } else {
16744 let rooted = workspace_root().join(&candidate);
16745 if rooted.exists() {
16746 rooted
16747 } else {
16748 workspace_root().join(candidate)
16749 }
16750 };
16751
16752 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16755 PathBuf::from(display_path(&canonical))
16756}
16757
16758fn dir_size_bytes(path: &Path) -> u64 {
16759 let mut total = 0u64;
16760 if let Ok(rd) = fs::read_dir(path) {
16761 for entry in rd.filter_map(Result::ok) {
16762 let p = entry.path();
16763 if p.is_file() {
16764 if let Ok(meta) = p.metadata() {
16765 total += meta.len();
16766 }
16767 } else if p.is_dir() {
16768 total += dir_size_bytes(&p);
16769 }
16770 }
16771 }
16772 total
16773}
16774
16775#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
16777 if bytes >= 1_073_741_824 {
16778 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16779 } else if bytes >= 1_048_576 {
16780 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16781 } else if bytes >= 1_024 {
16782 format!("{:.0} KB", bytes as f64 / 1_024.0)
16783 } else {
16784 format!("{bytes} B")
16785 }
16786}
16787
16788fn render_submodule_chips(
16789 root: &Path,
16790 submodules: &[(String, std::path::PathBuf)],
16791 out: &mut String,
16792) {
16793 use std::fmt::Write as _;
16794 let count = submodules.len();
16795 out.push_str(r#"<div class="submodule-preview-strip">"#);
16796 write!(
16797 out,
16798 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>"#,
16799 if count == 1 { "" } else { "s" }
16800 )
16801 .ok();
16802 out.push_str(r#"<div class="submodule-preview-chips">"#);
16803 for (sub_name, sub_rel_path) in submodules {
16804 let sub_abs = root.join(sub_rel_path);
16805 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16806 let mut sub_stats = PreviewStats::default();
16807 let mut sub_rows: Vec<PreviewRow> = Vec::new();
16808 let mut sub_langs: Vec<&'static str> = Vec::new();
16809 let mut sub_budget = PreviewBudget {
16810 shown: 0,
16811 max_entries: 2000,
16812 max_depth: 9,
16813 };
16814 let mut sub_next_id = 1usize;
16815 let _ = collect_preview_rows(
16816 &sub_abs,
16817 &sub_abs,
16818 0,
16819 None,
16820 &mut sub_next_id,
16821 &mut sub_budget,
16822 &mut sub_stats,
16823 &mut sub_rows,
16824 &mut sub_langs,
16825 &[],
16826 &[],
16827 );
16828 let stats_json = format!(
16829 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16830 sub_stats.directories,
16831 sub_stats.files,
16832 sub_stats.supported,
16833 sub_stats.skipped,
16834 sub_stats.unsupported
16835 );
16836 write!(
16837 out,
16838 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>"#,
16839 escape_html(sub_name),
16840 escape_html(&sub_rel_path.to_string_lossy()),
16841 escape_html(&sub_size),
16842 escape_html(&stats_json),
16843 escape_html(sub_name),
16844 escape_html(&sub_size),
16845 )
16846 .ok();
16847 }
16848 out.push_str(
16849 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
16850 );
16851 out.push_str(r"</div>");
16852}
16853
16854fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
16858 use std::fmt::Write as _;
16859 const MAX_LISTED: usize = 5;
16860 let total = layout.nested_repos.len();
16861
16862 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
16863 if layout.root_is_repo {
16864 write!(
16865 out,
16866 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>",
16867 if total == 1 { "repository" } else { "repositories" }
16868 )
16869 .ok();
16870 } else {
16871 write!(
16872 out,
16873 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>"
16874 )
16875 .ok();
16876 }
16877
16878 out.push_str(r#"<div class="repo-pick-row">"#);
16879 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
16880 let abs = root.join(rel);
16881 let abs_display = display_path(&abs);
16882 let label = rel.to_string_lossy().replace('\\', "/");
16883 write!(
16884 out,
16885 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
16886 escape_html(&abs_display),
16887 escape_html(&label)
16888 )
16889 .ok();
16890 }
16891 if total > MAX_LISTED {
16892 write!(
16893 out,
16894 r#"<span class="repo-pick-more">and {} more</span>"#,
16895 total - MAX_LISTED
16896 )
16897 .ok();
16898 }
16899 out.push_str(r"</div>");
16900
16901 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
16902 out.push_str(r"</div>");
16903}
16904
16905fn render_language_pills_row(languages: &[&str], out: &mut String) {
16906 use std::fmt::Write as _;
16907 if languages.is_empty() {
16908 out.push_str(
16909 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16910 );
16911 return;
16912 }
16913 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16914 for language in languages {
16915 if let Some(icon) = language_icon_file(language) {
16916 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();
16917 } else if let Some(svg) = language_inline_svg(language) {
16918 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();
16919 } else {
16920 write!(
16921 out,
16922 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16923 escape_html(&language.to_ascii_lowercase()),
16924 escape_html(language)
16925 )
16926 .ok();
16927 }
16928 }
16929}
16930
16931#[allow(clippy::too_many_lines)]
16932fn build_preview_html(
16933 root: &Path,
16934 include_patterns: &[String],
16935 exclude_patterns: &[String],
16936) -> Result<String> {
16937 if !root.exists() {
16938 return Ok(format!(
16939 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16940 escape_html(&display_path(root))
16941 ));
16942 }
16943
16944 let _selected = display_path(root);
16945 let mut stats = PreviewStats::default();
16946 let mut rows = Vec::new();
16947 let mut languages = Vec::new();
16948 let mut budget = PreviewBudget {
16949 shown: 0,
16950 max_entries: 600,
16951 max_depth: 9,
16952 };
16953 let mut next_row_id = 1usize;
16954
16955 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16956 || root.to_string_lossy().into_owned(),
16957 std::string::ToString::to_string,
16958 );
16959 let root_modified = root
16960 .metadata()
16961 .ok()
16962 .and_then(|meta| meta.modified().ok())
16963 .map_or_else(|| "-".to_string(), format_system_time);
16964
16965 rows.push(PreviewRow {
16966 row_id: 0,
16967 parent_row_id: None,
16968 depth: 0,
16969 name: format!("{root_name}/"),
16970 kind: PreviewKind::Dir,
16971 is_dir: true,
16972 language: None,
16973 modified: root_modified,
16974 type_label: "Directory".to_string(),
16975 });
16976 collect_preview_rows(
16977 root,
16978 root,
16979 0,
16980 Some(0),
16981 &mut next_row_id,
16982 &mut budget,
16983 &mut stats,
16984 &mut rows,
16985 &mut languages,
16986 include_patterns,
16987 exclude_patterns,
16988 )?;
16989
16990 let root_size = format_dir_size(dir_size_bytes(root));
16991
16992 let mut out = String::new();
16993 write!(
16994 out,
16995 r#"<div class="explorer-wrap" data-project-size="{}">"#,
16996 escape_html(&root_size)
16997 )
16998 .ok();
16999 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17000 out.push_str(r#"<div class="explorer-title-group">"#);
17001 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17002 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17003 out.push_str(r"</div></div>");
17004
17005 out.push_str(r#"<div class="scope-stats">"#);
17006 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();
17007 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();
17008 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();
17009 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();
17010 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();
17011 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>"#);
17012 out.push_str(r"</div>");
17013
17014 let submodules = sloc_core::detect_submodules(root);
17015 if !submodules.is_empty() {
17016 render_submodule_chips(root, &submodules, &mut out);
17017 }
17018
17019 let repo_layout = sloc_core::detect_repository_layout(root);
17020 if repo_layout.has_multiple_repos() {
17021 render_multi_repo_warning(root, &repo_layout, &mut out);
17022 }
17023
17024 out.push_str(r#"<div class="scope-info-row">"#);
17025 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17026 render_language_pills_row(&languages, &mut out);
17027 out.push_str(r"</div></div>");
17028 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>"#);
17029 out.push_str(r"</div>");
17030
17031 out.push_str(r#"<div class="file-explorer-shell">"#);
17032 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>"#);
17033 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>"#);
17034 out.push_str(r#"<div class="file-explorer-tree">"#);
17035 for row in rows {
17036 let status_label = row.kind.label();
17037 let lang_attr = row.language.unwrap_or("");
17038 let toggle_html = if row.is_dir {
17039 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17040 .to_string()
17041 } else {
17042 r#"<span class="tree-bullet">•</span>"#.to_string()
17043 };
17044 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();
17045 }
17046 if budget.shown >= budget.max_entries {
17047 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>"#);
17048 }
17049 out.push_str(r"</div></div></div>");
17050
17051 Ok(out)
17052}
17053
17054#[derive(Default)]
17055struct PreviewStats {
17056 directories: usize,
17057 files: usize,
17058 supported: usize,
17059 skipped: usize,
17060 unsupported: usize,
17061}
17062
17063struct PreviewRow {
17064 row_id: usize,
17065 parent_row_id: Option<usize>,
17066 depth: usize,
17067 name: String,
17068 kind: PreviewKind,
17069 is_dir: bool,
17070 language: Option<&'static str>,
17071 modified: String,
17072 type_label: String,
17073}
17074
17075#[derive(Copy, Clone)]
17076enum PreviewKind {
17077 Dir,
17078 Supported,
17079 Skipped,
17080 Unsupported,
17081}
17082
17083impl PreviewKind {
17084 const fn filter_key(self) -> &'static str {
17085 match self {
17086 Self::Dir => "dir",
17087 Self::Supported => "supported",
17088 Self::Skipped => "skipped",
17089 Self::Unsupported => "unsupported",
17090 }
17091 }
17092
17093 const fn label(self) -> &'static str {
17094 match self {
17095 Self::Dir => "dir",
17096 Self::Supported => "supported",
17097 Self::Skipped => "skipped by policy",
17098 Self::Unsupported => "unsupported",
17099 }
17100 }
17101
17102 const fn badge_class(self) -> &'static str {
17103 match self {
17104 Self::Dir => "badge badge-dir",
17105 Self::Supported => "badge badge-scan",
17106 Self::Skipped => "badge badge-skip",
17107 Self::Unsupported => "badge badge-unsupported",
17108 }
17109 }
17110
17111 const fn node_class(self) -> &'static str {
17112 match self {
17113 Self::Dir => "tree-node-dir",
17114 Self::Supported => "tree-node-supported",
17115 Self::Skipped => "tree-node-skipped",
17116 Self::Unsupported => "tree-node-unsupported",
17117 }
17118 }
17119}
17120
17121struct PreviewBudget {
17122 shown: usize,
17123 max_entries: usize,
17124 max_depth: usize,
17125}
17126
17127#[allow(clippy::too_many_arguments)]
17130fn handle_preview_dir_entry(
17131 root: &Path,
17132 path: &Path,
17133 name: &str,
17134 modified: String,
17135 depth: usize,
17136 parent_row_id: Option<usize>,
17137 row_id: usize,
17138 next_row_id: &mut usize,
17139 budget: &mut PreviewBudget,
17140 stats: &mut PreviewStats,
17141 rows: &mut Vec<PreviewRow>,
17142 languages: &mut Vec<&'static str>,
17143 include_patterns: &[String],
17144 exclude_patterns: &[String],
17145) -> Result<()> {
17146 let relative = preview_relative_path(root, path);
17147 if should_skip_preview_directory(&relative, exclude_patterns) {
17148 return Ok(());
17149 }
17150 stats.directories += 1;
17151 rows.push(PreviewRow {
17152 row_id,
17153 parent_row_id,
17154 depth: depth + 1,
17155 name: format!("{name}/"),
17156 kind: PreviewKind::Dir,
17157 is_dir: true,
17158 language: None,
17159 modified,
17160 type_label: "Directory".to_string(),
17161 });
17162 budget.shown += 1;
17163 if !matches!(name, ".git" | "node_modules" | "target") {
17164 collect_preview_rows(
17165 root,
17166 path,
17167 depth + 1,
17168 Some(row_id),
17169 next_row_id,
17170 budget,
17171 stats,
17172 rows,
17173 languages,
17174 include_patterns,
17175 exclude_patterns,
17176 )?;
17177 }
17178 Ok(())
17179}
17180
17181#[allow(clippy::too_many_arguments)]
17183fn handle_preview_file_entry(
17184 root: &Path,
17185 path: &Path,
17186 name: &str,
17187 modified: String,
17188 depth: usize,
17189 parent_row_id: Option<usize>,
17190 row_id: usize,
17191 budget: &mut PreviewBudget,
17192 stats: &mut PreviewStats,
17193 rows: &mut Vec<PreviewRow>,
17194 languages: &mut Vec<&'static str>,
17195 include_patterns: &[String],
17196 exclude_patterns: &[String],
17197) {
17198 let relative = preview_relative_path(root, path);
17199 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17200 return;
17201 }
17202 stats.files += 1;
17203 let kind = classify_preview_file(name);
17204 match kind {
17205 PreviewKind::Supported => stats.supported += 1,
17206 PreviewKind::Skipped => stats.skipped += 1,
17207 PreviewKind::Unsupported => stats.unsupported += 1,
17208 PreviewKind::Dir => {}
17209 }
17210 let language = detect_language_name(name);
17211 if let Some(lang) = language {
17212 if !languages.contains(&lang) {
17213 languages.push(lang);
17214 }
17215 }
17216 rows.push(PreviewRow {
17217 row_id,
17218 parent_row_id,
17219 depth: depth + 1,
17220 name: name.to_owned(),
17221 kind,
17222 is_dir: false,
17223 language,
17224 modified,
17225 type_label: preview_type_label(name, language, kind),
17226 });
17227 budget.shown += 1;
17228}
17229
17230#[allow(clippy::too_many_arguments)]
17231#[allow(clippy::too_many_lines)]
17232fn collect_preview_rows(
17233 root: &Path,
17234 dir: &Path,
17235 depth: usize,
17236 parent_row_id: Option<usize>,
17237 next_row_id: &mut usize,
17238 budget: &mut PreviewBudget,
17239 stats: &mut PreviewStats,
17240 rows: &mut Vec<PreviewRow>,
17241 languages: &mut Vec<&'static str>,
17242 include_patterns: &[String],
17243 exclude_patterns: &[String],
17244) -> Result<()> {
17245 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17246 return Ok(());
17247 }
17248
17249 let mut entries = fs::read_dir(dir)
17250 .with_context(|| format!("failed to read directory {}", dir.display()))?
17251 .filter_map(std::result::Result::ok)
17252 .collect::<Vec<_>>();
17253 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17254
17255 for entry in entries {
17256 if budget.shown >= budget.max_entries {
17257 break;
17258 }
17259
17260 let path = entry.path();
17261 let name = entry.file_name().to_string_lossy().into_owned();
17262 let Ok(metadata) = entry.metadata() else {
17263 continue;
17264 };
17265 let row_id = *next_row_id;
17266 *next_row_id += 1;
17267 let modified = metadata
17268 .modified()
17269 .ok()
17270 .map_or_else(|| "-".to_string(), format_system_time);
17271
17272 if metadata.is_dir() {
17273 handle_preview_dir_entry(
17274 root,
17275 &path,
17276 &name,
17277 modified,
17278 depth,
17279 parent_row_id,
17280 row_id,
17281 next_row_id,
17282 budget,
17283 stats,
17284 rows,
17285 languages,
17286 include_patterns,
17287 exclude_patterns,
17288 )?;
17289 continue;
17290 }
17291
17292 if metadata.is_file() {
17293 handle_preview_file_entry(
17294 root,
17295 &path,
17296 &name,
17297 modified,
17298 depth,
17299 parent_row_id,
17300 row_id,
17301 budget,
17302 stats,
17303 rows,
17304 languages,
17305 include_patterns,
17306 exclude_patterns,
17307 );
17308 }
17309 }
17310
17311 Ok(())
17312}
17313
17314fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17315 if let Some(language) = language {
17316 return format!("{language} source");
17317 }
17318 let lower = name.to_ascii_lowercase();
17319 let ext = Path::new(&lower)
17320 .extension()
17321 .and_then(|e| e.to_str())
17322 .unwrap_or("");
17323 match kind {
17324 PreviewKind::Skipped => {
17325 if lower.ends_with(".min.js") {
17326 "Minified asset".to_string()
17327 } else if [
17328 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17329 ]
17330 .contains(&ext)
17331 {
17332 "Binary or archive".to_string()
17333 } else {
17334 "Skipped file".to_string()
17335 }
17336 }
17337 PreviewKind::Unsupported => {
17338 if ext.is_empty() {
17339 "Unsupported file".to_string()
17340 } else {
17341 format!("{} file", ext.to_ascii_uppercase())
17342 }
17343 }
17344 PreviewKind::Supported => "Supported source".to_string(),
17345 PreviewKind::Dir => "Directory".to_string(),
17346 }
17347}
17348
17349fn format_system_time(time: SystemTime) -> String {
17350 #[allow(clippy::cast_possible_wrap)]
17351 let secs = match time.duration_since(UNIX_EPOCH) {
17352 Ok(duration) => duration.as_secs() as i64,
17353 Err(_) => return "-".to_string(),
17354 };
17355 let days = secs.div_euclid(86_400);
17356 let secs_of_day = secs.rem_euclid(86_400);
17357 let (year, month, day) = civil_from_days(days);
17358 let hour = secs_of_day / 3_600;
17359 let minute = (secs_of_day % 3_600) / 60;
17360 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17361}
17362
17363#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17364fn civil_from_days(days: i64) -> (i32, u32, u32) {
17365 let z = days + 719_468;
17366 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17367 let doe = z - era * 146_097;
17368 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17369 let y = yoe + era * 400;
17370 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17371 let mp = (5 * doy + 2) / 153;
17372 let d = doy - (153 * mp + 2) / 5 + 1;
17373 let m = mp + if mp < 10 { 3 } else { -9 };
17374 let year = y + i64::from(m <= 2);
17375 (year as i32, m as u32, d as u32)
17376}
17377
17378#[allow(clippy::case_sensitive_file_extension_comparisons)]
17381fn detect_language_name(name: &str) -> Option<&'static str> {
17382 let lower = name.to_ascii_lowercase();
17383 if lower.ends_with(".c") || lower.ends_with(".h") {
17384 Some("C")
17385 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17386 .iter()
17387 .any(|s| lower.ends_with(s))
17388 {
17389 Some("C++")
17390 } else if lower.ends_with(".cs") {
17391 Some("C#")
17392 } else if lower.ends_with(".py") {
17393 Some("Python")
17394 } else if lower.ends_with(".sh") {
17395 Some("Shell")
17396 } else if [".ps1", ".psm1", ".psd1"]
17397 .iter()
17398 .any(|s| lower.ends_with(s))
17399 {
17400 Some("PowerShell")
17401 } else {
17402 None
17403 }
17404}
17405
17406fn language_icon_file(language: &str) -> Option<&'static str> {
17407 match language {
17408 "C" => Some("c.png"),
17409 "C++" => Some("cpp.png"),
17410 "C#" => Some("c-sharp.png"),
17411 "Python" => Some("python.png"),
17412 "Shell" => Some("shell.png"),
17413 "PowerShell" => Some("powershell.png"),
17414 "JavaScript" => Some("java-script.png"),
17415 "HTML" => Some("html-5.png"),
17416 "Java" => Some("java.png"),
17417 "Visual Basic" => Some("visual-basic.png"),
17418 "Assembly" => Some("asm.png"),
17419 "Go" => Some("go.png"),
17420 "R" => Some("r.png"),
17421 "XML" => Some("xml.png"),
17422 "Groovy" => Some("groovy.png"),
17423 "Dockerfile" => Some("docker.png"),
17424 "Makefile" => Some("makefile.svg"),
17425 "Perl" => Some("perl.svg"),
17426 _ => None,
17427 }
17428}
17429
17430fn language_inline_svg(language: &str) -> Option<&'static str> {
17435 match language {
17436 "Rust" => Some(
17437 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>"##,
17438 ),
17439 "TypeScript" => Some(
17440 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>"##,
17441 ),
17442 _ => None,
17443 }
17444}
17445
17446#[allow(clippy::case_sensitive_file_extension_comparisons)]
17449fn classify_preview_file(name: &str) -> PreviewKind {
17450 let lower = name.to_ascii_lowercase();
17451
17452 let scannable = [
17453 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17454 ".psm1", ".psd1",
17455 ]
17456 .iter()
17457 .any(|suffix| lower.ends_with(suffix));
17458
17459 if scannable {
17460 PreviewKind::Supported
17461 } else if lower.ends_with(".min.js")
17462 || lower.ends_with(".lock")
17463 || lower.ends_with(".png")
17464 || lower.ends_with(".jpg")
17465 || lower.ends_with(".jpeg")
17466 || lower.ends_with(".gif")
17467 || lower.ends_with(".zip")
17468 || lower.ends_with(".pdf")
17469 || lower.ends_with(".pyc")
17470 || lower.ends_with(".xz")
17471 || lower.ends_with(".tar")
17472 || lower.ends_with(".gz")
17473 {
17474 PreviewKind::Skipped
17475 } else {
17476 PreviewKind::Unsupported
17477 }
17478}
17479
17480fn preview_relative_path(root: &Path, path: &Path) -> String {
17481 path.strip_prefix(root)
17482 .ok()
17483 .unwrap_or(path)
17484 .to_string_lossy()
17485 .replace('\\', "/")
17486 .trim_matches('/')
17487 .to_string()
17488}
17489
17490fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17491 if relative.is_empty() {
17492 return false;
17493 }
17494
17495 exclude_patterns.iter().any(|pattern| {
17496 wildcard_match(pattern, relative)
17497 || wildcard_match(pattern, &format!("{relative}/"))
17498 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17499 })
17500}
17501
17502fn should_include_preview_file(
17503 relative: &str,
17504 include_patterns: &[String],
17505 exclude_patterns: &[String],
17506) -> bool {
17507 if relative.is_empty() {
17508 return true;
17509 }
17510
17511 let included = include_patterns.is_empty()
17512 || include_patterns
17513 .iter()
17514 .any(|pattern| wildcard_match(pattern, relative));
17515 let excluded = exclude_patterns
17516 .iter()
17517 .any(|pattern| wildcard_match(pattern, relative));
17518
17519 included && !excluded
17520}
17521
17522fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17523 let pattern = pattern.trim().replace('\\', "/");
17524 let candidate = candidate.trim().replace('\\', "/");
17525 let p = pattern.as_bytes();
17526 let c = candidate.as_bytes();
17527 let mut pi = 0usize;
17528 let mut ci = 0usize;
17529 let mut star: Option<usize> = None;
17530 let mut star_match = 0usize;
17531
17532 while ci < c.len() {
17533 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17534 pi += 1;
17535 ci += 1;
17536 } else if pi < p.len() && p[pi] == b'*' {
17537 while pi < p.len() && p[pi] == b'*' {
17538 pi += 1;
17539 }
17540 star = Some(pi);
17541 star_match = ci;
17542 } else if let Some(star_pi) = star {
17543 star_match += 1;
17544 ci = star_match;
17545 pi = star_pi;
17546 } else {
17547 return false;
17548 }
17549 }
17550
17551 while pi < p.len() && p[pi] == b'*' {
17552 pi += 1;
17553 }
17554
17555 pi == p.len()
17556}
17557
17558fn escape_html(value: &str) -> String {
17559 value
17560 .replace('&', "&")
17561 .replace('<', "<")
17562 .replace('>', ">")
17563 .replace('"', """)
17564 .replace('\'', "'")
17565}
17566
17567#[derive(Clone)]
17568struct SubmoduleRow {
17569 name: String,
17570 relative_path: String,
17571 files_analyzed: u64,
17572 code_lines: u64,
17573 comment_lines: u64,
17574 blank_lines: u64,
17575 total_physical_lines: u64,
17576 html_url: Option<String>,
17577}
17578
17579#[derive(Template)]
17580#[template(
17581 source = r##"
17582<!doctype html>
17583<html lang="en">
17584<head>
17585 <meta charset="utf-8">
17586 <title>OxideSLOC | tmp-sloc</title>
17587 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17588 <style nonce="{{ csp_nonce }}">
17589 :root {
17590 --bg: #efe9e2;
17591 --surface: #fcfaf7;
17592 --surface-2: #f7f0e8;
17593 --surface-3: #efe3d5;
17594 --line: #dfcfbf;
17595 --line-strong: #cfb29c;
17596 --text: #2f241c;
17597 --muted: #6f6257;
17598 --muted-2: #917f71;
17599 --nav: #b85d33;
17600 --nav-2: #7a371b;
17601 --accent: #2563eb;
17602 --accent-2: #1d4ed8;
17603 --oxide: #b85d33;
17604 --oxide-2: #8f4220;
17605 --success-bg: #eaf9ee;
17606 --success-text: #1c8746;
17607 --warn-bg: #fff2d8;
17608 --warn-text: #926000;
17609 --danger-bg: #fdeaea;
17610 --danger-text: #b33b3b;
17611 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17612 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17613 --radius: 14px;
17614 }
17615
17616 body.dark-theme {
17617 --bg: #1b1511;
17618 --surface: #261c17;
17619 --surface-2: #2d221d;
17620 --surface-3: #372922;
17621 --line: #524238;
17622 --line-strong: #6c5649;
17623 --text: #f5ece6;
17624 --muted: #c7b7aa;
17625 --muted-2: #aa9485;
17626 --nav: #b85d33;
17627 --nav-2: #7a371b;
17628 --accent: #6f9bff;
17629 --accent-2: #4a78ee;
17630 --oxide: #d37a4c;
17631 --oxide-2: #b35428;
17632 --success-bg: #163927;
17633 --success-text: #8fe2a8;
17634 --warn-bg: #3c2d11;
17635 --warn-text: #f3cb75;
17636 --danger-bg: #3d1f1f;
17637 --danger-text: #ff9f9f;
17638 --shadow: 0 14px 28px rgba(0,0,0,0.28);
17639 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17640 }
17641
17642 * { box-sizing: border-box; }
17643 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); }
17644 html { overflow-y: scroll; }
17645 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17646 .top-nav, .page, .loading { position: relative; z-index: 2; }
17647 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17648 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17649 .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); }
17650 .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; }
17651 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17652 .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)); }
17653 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17654 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17655 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17656 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17657 .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; }
17658 .nav-project-pill.visible { display:inline-flex; }
17659 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17660 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17661 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17662 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17663 @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; } }
17664 .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; }
17665 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17666 .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; }
17667 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17668 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17669 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17670 .theme-toggle .icon-sun { display:none; }
17671 body.dark-theme .theme-toggle .icon-sun { display:block; }
17672 body.dark-theme .theme-toggle .icon-moon { display:none; }
17673 .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;}
17674 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17675 .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);}
17676 .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;}
17677 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17678 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17679 .settings-modal-body{padding:14px 16px 16px;}
17680 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17681 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17682 .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;}
17683 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17684 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17685 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17686 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17687 .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;}
17688 .tz-select:focus{border-color:var(--oxide);}
17689 .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; }
17690 .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;}
17691 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17692 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17693 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17694 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17695 .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; }
17696 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17697 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17698 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17699 .wb-stats-header { padding: 10px 24px 0; }
17700 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17701 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17702 .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; }
17703 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17704 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17705 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17706 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17707 .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; }
17708 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17709 .ws-stat-analyzers { position: relative; }
17710 .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; }
17711 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17712 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17713 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17714 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17715 .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; }
17716 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17717 .ws-divider { display: none; }
17718 .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%; }
17719 .ws-path-link:hover { color:var(--oxide); }
17720 body.dark-theme .ws-path-link { color:var(--oxide); }
17721 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17722 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17723 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17724 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17725 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17726 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17727 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17728 .ws-mini-box-lg { flex:2 1 0; }
17729 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17730 .ws-mini-box-br { flex:1.5 1 0; }
17731 .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; }
17732 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17733 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17734 #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; }
17735 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17736 .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; }
17737 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17738 .git-source-banner strong { font-weight:800; color:var(--text); }
17739 .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; }
17740 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17741 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17742 .git-source-banner a:hover { text-decoration:underline; }
17743 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17744 .path-scope-sep { background:var(--line); margin:4px 14px; }
17745 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17746 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17747 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17748 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17749 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17750 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17751 .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; }
17752 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17753 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17754 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17755 .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; }
17756 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17757 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17758 [data-wb-tip] { cursor:help; }
17759 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17760 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17761 .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; }
17762 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17763 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17764 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17765 .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; }
17766 .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); }
17767 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17768 .side-info-card { padding: 18px; }
17769 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17770 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17771 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17772 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17773 .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); }
17774 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17775 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17776 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17777 .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; }
17778 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17779 .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; }
17780 .side-stack::-webkit-scrollbar { display: none; }
17781 .step-nav { padding: 20px 16px; }
17782 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17783 .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; }
17784 .step-button:hover { background: var(--surface-2); }
17785 .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); }
17786 .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; }
17787 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17788 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17789 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17790 .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); }
17791 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17792 .step-nav-sum-row:last-child { border-bottom:none; }
17793 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17794 .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; }
17795 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17796 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17797 .quick-scan-section { padding: 10px 4px 14px; }
17798 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17799 .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; }
17800 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17801 .quick-scan-btn:active { transform:translateY(0); }
17802 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17803 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17804 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17805 @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);} }
17806 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17807 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17808 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17809 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17810 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17811 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17812 .step-button.done .step-check { opacity:1; }
17813 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17814 .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; }
17815 .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; }
17816 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17817 .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; }
17818 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17819 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17820 .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; }
17821 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17822 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17823 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17824 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17825 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17826 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17827 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17828 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17829 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17830 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17831 .card-body { padding: 22px; }
17832 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17833 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17834 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17835 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17836 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17837 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17838 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17839 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17840 .field { min-width:0; }
17841 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17842 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; }
17843 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); }
17844 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17845 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); }
17846 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17847 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17848 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17849 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17850 .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; }
17851 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17852 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17853 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17854 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17855 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17856 .input-group.compact { grid-template-columns: 1fr auto auto; }
17857 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17858 .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)); }
17859 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17860 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17861 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17862 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17863 .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; }
17864 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17865 .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; }
17866 .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); }
17867 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17868 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17869 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17870 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17871 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17872 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17873 @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; } }
17874 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17875 button.secondary { background: var(--surface); }
17876 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17877 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17878 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17879 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17880 .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); }
17881 .section + .wizard-actions { border-top: none; padding-top: 0; }
17882 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17883 .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; }
17884 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17885 .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; }
17886 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17887 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17888 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17889 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17890 .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); }
17891 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17892 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17893 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17894 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17895 .field-help-grid.coupled-help { margin-top: 12px; }
17896 .field-help-grid.preset-grid { align-items: start; }
17897 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17898 .preset-inline-row .field { margin: 0; }
17899 .preset-inline-row .explainer-card { margin: 0; }
17900 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17901 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17902 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17903 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17904 .preset-kv-row > :last-child { flex:1; min-width:0; }
17905 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17906 .output-field-row .field { margin: 0; }
17907 .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; }
17908 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17909 .step3-subtitle { margin-bottom: 10px; max-width: none; }
17910 .counting-intro { margin-bottom: 8px; max-width: none; }
17911 .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; }
17912 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17913 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17914 .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; }
17915 .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; }
17916 .section-spacer-top { margin-top: 28px; }
17917 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17918 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17919 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17920 .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); }
17921 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17922 .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; }
17923 .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; }
17924 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17925 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17926 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17927 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17928 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17929 .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; }
17930 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17931 .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); }
17932 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; }
17933 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; }
17934 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17935 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17936 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17937 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17938 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17939 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17940 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17941 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17942 .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); }
17943 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17944 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17945 .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; }
17946 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17947 .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; }
17948 .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; }
17949 .always-tracked-tip-body { flex:1; min-width:0; }
17950 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17951 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17952 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17953 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17954 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17955 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17956 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17957 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17958 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17959 .advanced-rule-description strong { color: var(--text); }
17960 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17961 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17962 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17963 .review-link:hover { text-decoration: underline; }
17964 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17965 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17966 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17967 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17968 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17969 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17970 .review-card ul { padding-left: 18px; margin: 0; }
17971 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17972 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17973 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17974 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17975 .review-card { min-height: 0; }
17976 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17977 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17978 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17979 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17980 .lang-overflow-chip { position:relative; cursor:default; }
17981 .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; }
17982 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17983 .git-inline-row { align-items:start; }
17984 .mixed-line-card { display:flex; flex-direction:column; }
17985 .preset-inline-row .toggle-card { justify-content: center; }
17986 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17987 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17988 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17989 .explorer-title { font-size: 18px; font-weight: 850; }
17990 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17991 .explorer-subtitle.wide { max-width: none; }
17992 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17993 .better-spacing { align-items:flex-start; justify-content:flex-end; }
17994 .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; }
17995 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17996 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17997 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17998 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17999 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18000 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18001 .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; }
18002 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18003 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18004 .scope-stat-button.supported { background: var(--success-bg); }
18005 .scope-stat-button.skipped { background: var(--warn-bg); }
18006 .scope-stat-button.unsupported { background: var(--danger-bg); }
18007 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18008 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18009 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18010 [data-tooltip] { position: relative; }
18011 [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); }
18012 [data-tooltip]:hover::after { display: block; }
18013 .scope-stat-button[data-tooltip] { cursor: pointer; }
18014 .badge[data-tooltip] { cursor: help; }
18015 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18016 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18017 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18018 .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; }
18019 .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; }
18020 code { display:inline-block; margin-top:0; padding:2px 7px; }
18021 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18022 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18023 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18024 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18025 .language-pill.muted-pill { color: var(--muted); }
18026 button.language-pill { appearance:none; cursor:pointer; }
18027 .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); }
18028 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18029 .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; }
18030 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18031 .file-explorer-search-row { margin-left: auto; }
18032 .explorer-filter-select { min-width: 170px; width: 170px; }
18033 .explorer-search { min-width: 300px; width: 300px; }
18034 .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); }
18035 .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; }
18036 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18037 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18038 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18039 .file-explorer-tree { max-height: 640px; overflow:auto; }
18040 .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); }
18041 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18042 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18043 .tree-row.hidden-by-filter { display:none !important; }
18044 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18045 .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; }
18046 .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; }
18047 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18048 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18049 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18050 .tree-node-dir { color: var(--text); font-weight: 800; }
18051 .tree-node-supported { color: var(--success-text); }
18052 .tree-node-skipped { color: var(--warn-text); }
18053 .tree-node-unsupported { color: var(--danger-text); }
18054 .tree-node-more { color: var(--muted-2); font-style: italic; }
18055 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18056 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18057 .tree-status-cell { display:flex; justify-content:flex-start; }
18058 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18059 .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; }
18060 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18061 .preview-warning p { margin: 0 0 10px; }
18062 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18063 .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; }
18064 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18065 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18066 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18067 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18068 .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; }
18069 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18070 .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; }
18071 @keyframes prevSpin { to { transform:rotate(360deg); } }
18072 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18073 .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; }
18074 .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; }
18075 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18076 .preview-gate-info svg { width:16px; height:16px; }
18077 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18078 @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); } }
18079 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18080 .preview-loading-text { flex:1; min-width:0; }
18081 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18082 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18083 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18084 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18085 .cov-scan-idle { display:none; }
18086 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18087 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18088 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18089 .cov-scan-title { font-weight:600; font-size:12.5px; }
18090 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18091 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18092 .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; }
18093 .cov-scan-use:hover { opacity:.75; }
18094 .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; }
18095 .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; }
18096 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18097 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18098 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18099 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18100 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18101 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18102 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18103 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18104 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18105 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18106 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18107 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18108 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18109 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18110 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18111 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18112 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18113 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18114 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18115 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18116 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18117 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18118 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18119 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18120 .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); }
18121 .loading.active { display:flex; }
18122 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18123 gutter doesn't pull the centered card slightly left of true center. */
18124 body.modal-open { overflow: hidden; }
18125 .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; }
18126 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18127 .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; }
18128 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18129 .loading-card > * { position:relative; z-index:1; }
18130 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18131 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%); }
18132 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18133 .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; }
18134 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18135 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18136 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18137 .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; }
18138 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18139 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18140 .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; }
18141 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18142 .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; }
18143 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18144 .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; }
18145 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18146 .lc-step.done { color:var(--muted);opacity:0.55; }
18147 .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; }
18148 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18149 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18150 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18151 .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; }
18152 .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; }
18153 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18154 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18155 .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; }
18156 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18157 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18158 .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; }
18159 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18160 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18161 .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; }
18162 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18163 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18164 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18165 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18166 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18167 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18168 .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; }
18169 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18170 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18171 .hidden { display:none !important; }
18172 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18173 .site-footer a{color:var(--muted);}
18174 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18175 @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; } }
18176 .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;}
18177 @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));}}
18178 .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;}
18179 .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; }
18180 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18181 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18182 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18183 .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; }
18184 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18185 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18186 .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; }
18187 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18188 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18189 .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; }
18190 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18191 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18192 .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; }
18193 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18194 .info-icon-btn:hover { color:var(--text); }
18195 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); }
18196 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18197 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18198 .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;}
18199 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18200 .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;}
18201 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18202 #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);}
18203 #offline-file-banner.show{display:flex;}
18204 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18205 #offline-file-banner .ofb-text{flex:1;}
18206 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18207 #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;}
18208 #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;}
18209 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18210 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18211 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18212 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18213 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18214 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18215 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18216 </style>
18217</head>
18218<body id="page-top">
18219 <div id="offline-file-banner" role="alert">
18220 <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>
18221 <span class="ofb-text">
18222 Charts, images, and navigation require the oxide-sloc server.
18223 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18224 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18225 The metric tables below are fully readable without the server.
18226 </span>
18227 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18228 </div>
18229 <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>
18230 <div class="background-watermarks" aria-hidden="true">
18231 <img src="/images/logo/logo-text.png" alt="" />
18232 <img src="/images/logo/logo-text.png" alt="" />
18233 <img src="/images/logo/logo-text.png" alt="" />
18234 <img src="/images/logo/logo-text.png" alt="" />
18235 <img src="/images/logo/logo-text.png" alt="" />
18236 <img src="/images/logo/logo-text.png" alt="" />
18237 <img src="/images/logo/logo-text.png" alt="" />
18238 <img src="/images/logo/logo-text.png" alt="" />
18239 <img src="/images/logo/logo-text.png" alt="" />
18240 <img src="/images/logo/logo-text.png" alt="" />
18241 <img src="/images/logo/logo-text.png" alt="" />
18242 <img src="/images/logo/logo-text.png" alt="" />
18243 <img src="/images/logo/logo-text.png" alt="" />
18244 <img src="/images/logo/logo-text.png" alt="" />
18245 </div>
18246 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18247 <div class="top-nav">
18248 <div class="top-nav-inner">
18249 <a class="brand" href="/">
18250 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18251 <div class="brand-copy">
18252 <div class="brand-title">OxideSLOC</div>
18253 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18254 </div>
18255 </a>
18256 <div class="nav-project-slot">
18257 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18258 <span class="nav-project-label">Project</span>
18259 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18260 </div>
18261 </div>
18262 <div class="nav-status">
18263 <a class="nav-pill" href="/">Home</a>
18264 <div class="nav-dropdown">
18265 <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>
18266 <div class="nav-dropdown-menu">
18267 <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>
18268 </div>
18269 </div>
18270 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18271 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18272 <div class="nav-dropdown">
18273 <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>
18274 <div class="nav-dropdown-menu">
18275 <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>
18276 </div>
18277 </div>
18278 <div class="server-status-wrap" id="server-status-wrap">
18279 <div class="nav-pill server-online-pill" id="server-status-pill">
18280 <span class="status-dot" id="status-dot"></span>
18281 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18282 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18283 </div>
18284 <div class="server-status-tip">
18285 {% if server_mode %}
18286 OxideSLOC is running in server mode — accessible on your LAN.
18287 {% else %}
18288 OxideSLOC is running locally — only accessible from this machine.
18289 {% endif %}
18290 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18291 </div>
18292 </div>
18293 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18294 <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>
18295 </button>
18296 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18297 <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>
18298 <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>
18299 </button>
18300 </div>
18301 </div>
18302 </div>
18303
18304 <div class="loading" id="loading">
18305 <div class="loading-card" id="loading-card">
18306 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18307 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18308 <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>
18309 <div class="lc-steps" id="lc-steps">
18310 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18311 <div class="lc-step-arrow">›</div>
18312 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18313 <div class="lc-step-arrow">›</div>
18314 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18315 <div class="lc-step-arrow">›</div>
18316 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18317 </div>
18318 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18319 <div class="lc-metrics" id="lc-metrics">
18320 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18321 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18322 <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>
18323 <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>
18324 </div>
18325 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18326 <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>
18327 <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>
18328 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18329 <div class="lc-actions hidden" id="lc-actions">
18330 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18331 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18332 </div>
18333 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18334 <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>
18335 Cancel scan
18336 </button>
18337 </div>
18338 </div>
18339
18340 <div class="page">
18341 <div class="workbench-strip">
18342 <div class="workbench-box wb-stats">
18343 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18344 <span class="wb-stats-title">Analysis session</span>
18345 </div>
18346 <div class="ws-left">
18347 <div class="ws-stat ws-stat-analyzers">
18348 <span class="ws-label">Analyzers</span>
18349 <span class="ws-value">
18350 <span class="ws-badge">60 languages</span>
18351 </span>
18352 <div class="ws-lang-tooltip">
18353 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18354 <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>
18355 <div class="ws-lang-grid">
18356 <span class="ws-lang-item">Assembly</span>
18357 <span class="ws-lang-item">C</span>
18358 <span class="ws-lang-item">C++</span>
18359 <span class="ws-lang-item">C#</span>
18360 <span class="ws-lang-item">Clojure</span>
18361 <span class="ws-lang-item">CSS</span>
18362 <span class="ws-lang-item">Dart</span>
18363 <span class="ws-lang-item">Dockerfile</span>
18364 <span class="ws-lang-item">Elixir</span>
18365 <span class="ws-lang-item">Erlang</span>
18366 <span class="ws-lang-item">F#</span>
18367 <span class="ws-lang-item">Go</span>
18368 <span class="ws-lang-item">Groovy</span>
18369 <span class="ws-lang-item">Haskell</span>
18370 <span class="ws-lang-item">HTML</span>
18371 <span class="ws-lang-item">Java</span>
18372 <span class="ws-lang-item">JavaScript</span>
18373 <span class="ws-lang-item">Julia</span>
18374 <span class="ws-lang-item">Kotlin</span>
18375 <span class="ws-lang-item">Lua</span>
18376 <span class="ws-lang-item">Makefile</span>
18377 <span class="ws-lang-item">Nim</span>
18378 <span class="ws-lang-item">Obj-C</span>
18379 <span class="ws-lang-item">OCaml</span>
18380 <span class="ws-lang-item">Perl</span>
18381 <span class="ws-lang-item">PHP</span>
18382 <span class="ws-lang-item">PowerShell</span>
18383 <span class="ws-lang-item">Python</span>
18384 <span class="ws-lang-item">R</span>
18385 <span class="ws-lang-item">Ruby</span>
18386 <span class="ws-lang-item">Rust</span>
18387 <span class="ws-lang-item">Scala</span>
18388 <span class="ws-lang-item">SCSS</span>
18389 <span class="ws-lang-item">Shell</span>
18390 <span class="ws-lang-item">SQL</span>
18391 <span class="ws-lang-item">Svelte</span>
18392 <span class="ws-lang-item">Swift</span>
18393 <span class="ws-lang-item">TypeScript</span>
18394 <span class="ws-lang-item">Vue</span>
18395 <span class="ws-lang-item">XML</span>
18396 <span class="ws-lang-item">Zig</span>
18397 <span class="ws-lang-item">Solidity</span>
18398 <span class="ws-lang-item">Protobuf</span>
18399 <span class="ws-lang-item">HCL</span>
18400 <span class="ws-lang-item">GraphQL</span>
18401 <span class="ws-lang-item">Ada</span>
18402 <span class="ws-lang-item">VHDL</span>
18403 <span class="ws-lang-item">Verilog</span>
18404 <span class="ws-lang-item">Tcl</span>
18405 <span class="ws-lang-item">Pascal</span>
18406 <span class="ws-lang-item">Visual Basic</span>
18407 <span class="ws-lang-item">Lisp</span>
18408 <span class="ws-lang-item">Fortran</span>
18409 <span class="ws-lang-item">Nix</span>
18410 <span class="ws-lang-item">Crystal</span>
18411 <span class="ws-lang-item">D</span>
18412 <span class="ws-lang-item">GLSL</span>
18413 <span class="ws-lang-item">CMake</span>
18414 <span class="ws-lang-item">Elm</span>
18415 <span class="ws-lang-item">Awk</span>
18416 </div>
18417 </div>
18418 </div>
18419 <div class="ws-divider"></div>
18420 <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>
18421 <div class="ws-divider"></div>
18422 <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.">
18423 <span class="ws-label">Output</span>
18424 <span class="ws-value">
18425 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18426 <span id="ws-output-root">project/sloc</span>
18427 </button>
18428 </span>
18429 </div>
18430 </div>
18431 </div>
18432 <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.">
18433 <div class="ws-history-label">Scan history</div>
18434 <div class="ws-history-inner">
18435 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18436 <div class="ws-mini-label">Scans</div>
18437 <div class="ws-mini-value" id="ws-scan-count">—</div>
18438 </div>
18439 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18440 <div class="ws-mini-label">Last Scan</div>
18441 <div class="ws-mini-value" id="ws-last-scan">—</div>
18442 </div>
18443 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18444 <div class="ws-mini-label">Branch</div>
18445 <div class="ws-mini-value" id="ws-branch">—</div>
18446 </div>
18447 </div>
18448 </div>
18449 </div>
18450
18451 <div class="layout">
18452 <aside class="side-stack">
18453 <section class="step-nav">
18454 <h3>Guided scan setup</h3>
18455 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18456 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18457 Top of page
18458 </a>
18459 <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>
18460 <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>
18461 <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>
18462 <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>
18463
18464 <div class="step-steps-divider"></div>
18465
18466 <div class="step-nav-info" id="step-nav-info">
18467 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18468 <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>
18469 </div>
18470
18471 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18472 <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>
18473 <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>
18474 <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>
18475 </div>
18476
18477 <div class="quick-scan-divider"></div>
18478 <div class="quick-scan-section">
18479 <div class="quick-scan-label">No customization needed?</div>
18480 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18481 <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>
18482 Quick Scan
18483 </button>
18484 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18485 </div>
18486
18487 <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>
18488 <div class="sidebar-scroll-divider"></div>
18489 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18490 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18491 Skip to bottom
18492 </a>
18493 </section>
18494
18495 </aside>
18496
18497 <section class="card">
18498 <div class="card-header">
18499 <div class="card-title-row">
18500 <div>
18501 <h1 class="card-title">Guided scan configuration</h1>
18502 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18503 </div>
18504 <div class="wizard-progress" aria-label="Scan setup progress">
18505 <div class="wizard-progress-top">
18506 <span class="wizard-progress-label">Setup progress</span>
18507 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18508 </div>
18509 <div class="wizard-progress-track">
18510 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18511 </div>
18512 </div>
18513 </div>
18514 </div>
18515 <div class="card-body">
18516 <form method="post" action="/analyze" id="analyze-form">
18517 <div class="wizard-step active" data-step="1">
18518 <div class="section">
18519 <div class="section-kicker">Step 1</div>
18520 <h2>Select project and preview scope</h2>
18521 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18522 <div class="field">
18523 <label for="path">Project path</label>
18524 {% if !git_repo.is_empty() %}
18525 <div class="git-source-banner">
18526 <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>
18527 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18528 <a href="/git-browser">← Back to Git Browser</a>
18529 </div>
18530 {% endif %}
18531 <div class="path-scope-grid">
18532 {% if !git_repo.is_empty() %}
18533 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18534 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18535 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18536 {% else %}
18537 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18538 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18539 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18540 {% endif %}
18541 <div class="path-scope-sep"></div>
18542 <div class="scope-legend-row">
18543 <span class="scope-legend-label">Scope legend:</span>
18544 <span class="scope-legend-badges">
18545 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18546 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18547 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18548 </span>
18549 </div>
18550 </div>
18551 {% if git_repo.is_empty() %}
18552 {% if server_mode %}
18553 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18554 ℹ️ Files are compressed and streamed — no fixed size limit.
18555 </div>
18556 {% endif %}
18557 <div class="path-info-row">
18558 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18559 <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>
18560 <span id="project-size-text">Project size: —</span>
18561 </button>
18562 </div>
18563 {% else %}
18564 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18565 {% endif %}
18566 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18567 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18568 </div>
18569
18570 <div class="scope-preview-divider" aria-hidden="true"></div>
18571
18572 <div id="preview-panel">
18573 <div class="preview-error">Loading preview...</div>
18574 </div>
18575 </div>
18576
18577 <div class="section" style="margin-top:14px;">
18578 <div class="preset-inline-row git-inline-row">
18579 <div class="toggle-card" style="margin:0;">
18580 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18581 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18582 <label class="checkbox">
18583 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18584 <div>
18585 <span>Detect and separate git submodules</span>
18586 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18587 </div>
18588 </label>
18589 </div>
18590 <div class="explainer-card prominent" style="margin:0;">
18591 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18592 <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>
18593 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18594 path = libs/core
18595 url = https://github.com/org/core.git
18596
18597[submodule "libs/ui"]
18598 path = libs/ui
18599 url = https://github.com/org/ui.git</div>
18600 </div>
18601 </div>
18602 </div>
18603
18604 <div class="section">
18605 <div class="field-grid">
18606 <div class="field">
18607 <div class="glob-label-row">
18608 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18609 <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>
18610 </div>
18611 <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>
18612 <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>
18613 </div>
18614 <div class="field">
18615 <div class="glob-label-row">
18616 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18617 </div>
18618 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
18619 <div id="quick-exclude-chips" class="quick-excl-row">
18620 <span class="quick-excl-label">Quick add:</span>
18621 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18622 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18623 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18624 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18625 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18626 <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>
18627 </div>
18628 <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>
18629 </div>
18630 </div>
18631 <div class="glob-guidance-grid">
18632 <div class="glob-guidance-card">
18633 <strong>How to read them</strong>
18634 <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>
18635 </div>
18636 <div class="glob-guidance-card">
18637 <strong>Common include examples</strong>
18638 <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>
18639 </div>
18640 <div class="glob-guidance-card">
18641 <strong>Common exclude examples</strong>
18642 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18643 </div>
18644 </div>
18645 </div>
18646
18647 <div class="section" style="margin-top:14px;">
18648 <div class="preset-inline-row git-inline-row">
18649 <div class="toggle-card" style="margin:0;">
18650 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18651 <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>
18652 <div class="field" style="margin:0;">
18653 <div class="input-group compact">
18654 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18655 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18656 </div>
18657 <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>
18658 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18659 </div>
18660 </div>
18661 <div class="explainer-card prominent" style="margin:0;">
18662 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18663 <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>
18664 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18665lcov --capture --directory . --output-file coverage/lcov.info
18666
18667# C / C++ — llvm-cov (LCOV)
18668llvm-profdata merge -sparse default.profraw -o default.profdata
18669llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18670
18671# C# — coverlet (Cobertura XML)
18672dotnet test --collect:"XPlat Code Coverage"
18673
18674# Python — pytest-cov (Cobertura XML)
18675pytest --cov --cov-report=xml
18676
18677# Python — coverage.py native JSON
18678coverage run -m pytest && coverage json # writes coverage.json
18679
18680# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18681./gradlew jacocoTestReport</div>
18682 </div>
18683 </div>
18684 </div>
18685
18686 <div class="wizard-actions">
18687 <div class="left"></div>
18688 <div class="right">
18689 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18690 <span class="preview-gate-spinner" aria-hidden="true"></span>
18691 <span class="preview-gate-text">Scanning project scope…</span>
18692 <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">
18693 <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>
18694 </button>
18695 </div>
18696 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18697 </div>
18698 </div>
18699 </div>
18700
18701 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18702 <div class="default-path-modal">
18703 <h3 id="default-path-title">
18704 <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>
18705 Proceed with the default sample test?
18706 </h3>
18707 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
18708 <p>You haven't selected your own project yet.</p>
18709 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18710 <div class="default-path-actions">
18711 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18712 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18713 </div>
18714 </div>
18715 </div>
18716
18717 <div class="wizard-step" data-step="2">
18718 <div class="section">
18719 <div class="section-kicker">Step 2</div>
18720 <h2>Choose counting behavior</h2>
18721 <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>
18722<div class="subsection-bar">Primary line classification</div>
18723 <div class="preset-kv-row">
18724 <div class="toggle-card mixed-line-card" style="margin:0;">
18725 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18726 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18727 <select id="mixed_line_policy" name="mixed_line_policy">
18728 <option value="code_only">Code only</option>
18729 <option value="code_and_comment">Code and comment</option>
18730 <option value="comment_only">Comment only</option>
18731 <option value="separate_mixed_category">Separate mixed category</option>
18732 </select>
18733 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18734 </div>
18735 <div class="explainer-card prominent" style="margin:0;">
18736 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18737 <div class="explainer-body" id="mixed-policy-description"></div>
18738 <div class="code-sample" id="mixed-policy-example"></div>
18739 </div>
18740 </div>
18741 </div>
18742
18743 <div class="subsection-bar">Additional scan rules</div>
18744 <div class="scan-rules-grid">
18745 <div class="preset-inline-row">
18746 <div class="toggle-card" style="margin:0;">
18747 <div class="field-help-title">Generated files</div>
18748 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18749 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18750 </div>
18751 <div class="explainer-card prominent" style="margin:0;">
18752 <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>
18753 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18754# Files matching codegen patterns are excluded:
18755# *.generated.cs *.pb.go *.g.dart</div>
18756 </div>
18757 </div>
18758 <div class="preset-inline-row">
18759 <div class="toggle-card" style="margin:0;">
18760 <div class="field-help-title">Minified files</div>
18761 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18762 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18763 </div>
18764 <div class="explainer-card prominent" style="margin:0;">
18765 <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>
18766 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18767# Heuristic: very long lines + low whitespace ratio
18768# jquery.min.js bundle.min.css → skipped</div>
18769 </div>
18770 </div>
18771 <div class="preset-inline-row">
18772 <div class="toggle-card" style="margin:0;">
18773 <div class="field-help-title">Vendor directories</div>
18774 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18775 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18776 </div>
18777 <div class="explainer-card prominent" style="margin:0;">
18778 <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>
18779 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18780# Directories named vendor/ node_modules/ third_party/
18781# → entire subtree is excluded from totals</div>
18782 </div>
18783 </div>
18784 <div class="preset-inline-row">
18785 <div class="toggle-card" style="margin:0;">
18786 <div class="field-help-title">Lockfiles and manifests</div>
18787 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18788 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18789 </div>
18790 <div class="explainer-card prominent" style="margin:0;">
18791 <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>
18792 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
18793# Files like package-lock.json Cargo.lock yarn.lock
18794# → skipped unless this is enabled</div>
18795 </div>
18796 </div>
18797 <div class="preset-inline-row">
18798 <div class="toggle-card" style="margin:0;">
18799 <div class="field-help-title">Binary handling</div>
18800 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18801 <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>
18802 </div>
18803 <div class="explainer-card prominent" style="margin:0;">
18804 <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>
18805 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
18806# Detected via long lines + low whitespace heuristic
18807# .png .exe .so → skipped silently</div>
18808 </div>
18809 </div>
18810 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18811 <div class="toggle-card" style="margin:0;">
18812 <div class="field-help-title">Python docstrings</div>
18813 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18814 <label class="checkbox">
18815 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18816 <span>Count as comment-style lines</span>
18817 </label>
18818 </div>
18819 <div class="explainer-card prominent" style="margin:0;">
18820 <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>
18821 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18822 </div>
18823 </div>
18824 </div>
18825 <div class="subsection-bar">IEEE 1045-1992 counting</div>
18826 <div class="scan-rules-grid">
18827 <div class="preset-inline-row">
18828 <div class="toggle-card" style="margin:0;">
18829 <div class="field-help-title">Continuation lines</div>
18830 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18831 <select name="continuation_line_policy" id="continuation_line_policy">
18832 <option value="each_physical_line" selected>Each physical line (default)</option>
18833 <option value="collapse_to_logical">Collapse to logical line</option>
18834 </select>
18835 </div>
18836 <div class="explainer-card prominent" style="margin:0;">
18837 <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>
18838 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18839 ((a) > (b) ? (a) : (b))
18840# each_physical_line → 2 SLOC
18841# collapse_to_logical → 1 SLOC</div>
18842 </div>
18843 </div>
18844 <div class="preset-inline-row">
18845 <div class="toggle-card" style="margin:0;">
18846 <div class="field-help-title">Block-comment blanks</div>
18847 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18848 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18849 <option value="count_as_comment" selected>Count as comment (default)</option>
18850 <option value="count_as_blank">Count as blank</option>
18851 </select>
18852 </div>
18853 <div class="explainer-card prominent" style="margin:0;">
18854 <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>
18855 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18856 * Summary line
18857 * ← blank inside block comment
18858 * Detail line
18859 */
18860# count_as_comment → blank counts toward comments
18861# count_as_blank → blank counts toward blanks</div>
18862 </div>
18863 </div>
18864 <div class="preset-inline-row">
18865 <div class="toggle-card" style="margin:0;">
18866 <div class="field-help-title">Compiler directives</div>
18867 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18868 <select name="count_compiler_directives" id="count_compiler_directives">
18869 <option value="enabled" selected>Include in code SLOC (default)</option>
18870 <option value="disabled">Exclude from code SLOC</option>
18871 </select>
18872 </div>
18873 <div class="explainer-card prominent" style="margin:0;">
18874 <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>
18875 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
18876#define BUF 256 ← compiler directive
18877int main() { … } ← code
18878# enabled → 3 code SLOC
18879# disabled → 1 code SLOC + 2 directive lines</div>
18880 </div>
18881 </div>
18882 </div>
18883
18884 <div class="subsection-bar">Code Style Analysis</div>
18885 <div class="scan-rules-grid">
18886 <div class="preset-inline-row">
18887 <div class="toggle-card" style="margin:0;">
18888 <div class="field-help-title">Style analysis</div>
18889 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18890 <select name="style_analysis_enabled" id="style_analysis_enabled">
18891 <option value="enabled" selected>Enabled (default)</option>
18892 <option value="disabled">Disabled — skip style scoring</option>
18893 </select>
18894 </div>
18895 <div class="explainer-card prominent" style="margin:0;">
18896 <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>
18897 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
18898# style_analysis_enabled = false (skip, faster scan)
18899# Disabling removes the Code Style section from the report.</div>
18900 </div>
18901 </div>
18902 <div class="preset-inline-row">
18903 <div class="toggle-card" style="margin:0;">
18904 <div class="field-help-title">Column-width threshold</div>
18905 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18906 <select name="style_col_threshold" id="style_col_threshold">
18907 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18908 <option value="100">100 columns (Uber Go, Google Java)</option>
18909 <option value="120">120 columns (Uber Go max, Kotlin)</option>
18910 </select>
18911 </div>
18912 <div class="explainer-card prominent" style="margin:0;">
18913 <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>
18914 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
18915# style_col_threshold = 100 (Uber Go, Google Java)
18916# style_col_threshold = 120 (Uber Go max, Kotlin)
18917# Files where <= 5% of lines exceed the limit
18918# are counted as "N-col compliant" in the report.</div>
18919 </div>
18920 </div>
18921 <div class="preset-inline-row">
18922 <div class="toggle-card" style="margin:0;">
18923 <div class="field-help-title">Score alert threshold</div>
18924 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18925 <select name="style_score_threshold" id="style_score_threshold">
18926 <option value="0" selected>Off — no threshold (default)</option>
18927 <option value="40">40% — flag poorly styled files</option>
18928 <option value="50">50% — flag below-average files</option>
18929 <option value="60">60% — flag below-good files</option>
18930 <option value="70">70% — flag below-strong files</option>
18931 </select>
18932 </div>
18933 <div class="explainer-card prominent" style="margin:0;">
18934 <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>
18935 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
18936# style_score_threshold = 50 (flag files < 50%)
18937# Low-scoring files get a red left-border in the
18938# per-file style breakdown table.</div>
18939 </div>
18940 </div>
18941 </div>
18942
18943 <div class="always-tracked-tip">
18944 <div class="always-tracked-tip-icon">ℹ</div>
18945 <div class="always-tracked-tip-body">
18946 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
18947 <h4>Comment and blank-line basics & Lines on the boundary</h4>
18948 <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>
18949 </div>
18950 </div>
18951
18952 <div class="subsection-bar">Advanced Metrics</div>
18953 <div class="scan-rules-grid">
18954 <div class="preset-inline-row">
18955 <div class="toggle-card" style="margin:0;">
18956 <div class="field-help-title">COCOMO mode</div>
18957 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18958 <select name="cocomo_mode" id="cocomo_mode">
18959 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18960 <option value="semi_detached">Semi-detached — mixed constraints</option>
18961 <option value="embedded">Embedded — tight hardware/OS constraints</option>
18962 </select>
18963 </div>
18964 <div class="explainer-card prominent" style="margin:0;">
18965 <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>
18966 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
18967# Semi-detached: Effort = 3.0 × KSLOC^1.12
18968# Embedded: Effort = 3.6 × KSLOC^1.20
18969# All modes: Schedule = 2.5 × Effort^d</div>
18970 </div>
18971 </div>
18972 <div class="preset-inline-row">
18973 <div class="toggle-card" style="margin:0;">
18974 <div class="field-help-title">Complexity alert</div>
18975 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18976 <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;" />
18977 </div>
18978 <div class="explainer-card prominent" style="margin:0;">
18979 <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>
18980 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18981# 50 = flag any file with > 50 branch points
18982# 100 = flag any file with > 100 branch points
18983# Files above the threshold are highlighted
18984# in the result page metric strip.</div>
18985 </div>
18986 </div>
18987 <div class="preset-inline-row">
18988 <div class="toggle-card" style="margin:0;">
18989 <div class="field-help-title">Git hotspots</div>
18990 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18991 <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;" />
18992 </div>
18993 <div class="explainer-card prominent" style="margin:0;">
18994 <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>
18995 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
18996# 30 = last month of activity
18997# 365 = last year
18998# 0 = disable the hotspots table
18999# Adds Commits + Last-changed columns to CSV.</div>
19000 </div>
19001 </div>
19002 <div class="preset-inline-row">
19003 <div class="toggle-card" style="margin:0;">
19004 <div class="field-help-title">Duplicate handling</div>
19005 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19006 <select name="exclude_duplicates" id="exclude_duplicates">
19007 <option value="disabled" selected>Detect and report only (default)</option>
19008 <option value="enabled">Detect and exclude from SLOC totals</option>
19009 </select>
19010 </div>
19011 <div class="explainer-card prominent" style="margin:0;">
19012 <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>
19013 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19014# detect only → all 3 counted in SLOC
19015# exclude dupes → 1 counted, 2 excluded
19016# Duplicate groups chip always shows the count.</div>
19017 </div>
19018 </div>
19019 <div class="always-tracked-tip" style="margin:8px 0 0;">
19020 <div class="always-tracked-tip-icon">ℹ</div>
19021 <div class="always-tracked-tip-body">
19022 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19023 <div class="always-tracked-metrics-row">
19024 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19025 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19026 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19027 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19028 </div>
19029 <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>
19030 </div>
19031 </div>
19032 </div>
19033
19034 <div class="wizard-actions">
19035 <div class="left">
19036 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19037 </div>
19038 <div class="right">
19039 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19040 </div>
19041 </div>
19042 </div>
19043
19044 <div class="wizard-step" data-step="3">
19045 <div class="section">
19046 <div class="section-kicker">Step 3</div>
19047 <h2>Output and report identity</h2>
19048 <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>
19049 <div class="preset-kv-row">
19050 <div class="toggle-card" style="margin:0;">
19051 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19052 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19053 <select id="scan_preset">
19054 <option value="balanced">Balanced local scan</option>
19055 <option value="code_focused">Code focused</option>
19056 <option value="comment_audit">Comment audit</option>
19057 <option value="deep_review">Deep review</option>
19058 </select>
19059 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19060 </div>
19061 <div class="explainer-card">
19062 <div class="field-help-title">Selected scan preset</div>
19063 <div class="explainer-body" id="scan-preset-description"></div>
19064 <div class="preset-summary-row" id="scan-preset-summary"></div>
19065 <div class="code-sample" id="scan-preset-example"></div>
19066 <div class="preset-note" id="scan-preset-note"></div>
19067 </div>
19068 </div>
19069 <hr class="step3-separator" />
19070 <div class="preset-kv-row">
19071 <div class="toggle-card" style="margin:0;">
19072 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19073 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19074 <select id="artifact_preset">
19075 <option value="review">Review bundle</option>
19076 <option value="full">Full bundle</option>
19077 <option value="html_only">HTML only</option>
19078 <option value="machine">Machine bundle</option>
19079 </select>
19080 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19081 </div>
19082 <div class="explainer-card">
19083 <div class="field-help-title">Selected artifact preset</div>
19084 <div class="explainer-body" id="artifact-preset-description"></div>
19085 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19086 <div class="code-sample" id="artifact-preset-example"></div>
19087 </div>
19088 </div>
19089 </div>
19090
19091 <div class="section section-spacer-top">
19092 <div class="output-field-row">
19093 <div class="field">
19094 <label for="output_dir">Output directory</label>
19095 {% if server_mode %}
19096 <div class="input-group compact">
19097 <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);" />
19098 </div>
19099 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19100 {% else %}
19101 <div class="input-group compact">
19102 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19103 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19104 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19105 </div>
19106 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19107 {% endif %}
19108 </div>
19109 <div class="output-field-aside">
19110 <strong>Where reports land</strong>
19111 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.
19112 </div>
19113 </div>
19114 </div>
19115
19116 <div class="section section-spacer-top">
19117 <div class="output-field-row">
19118 <div class="field">
19119 <label for="report_title">Report title</label>
19120 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19121 <div class="hint">Appears in HTML and PDF output headers.</div>
19122 </div>
19123 <div class="output-field-aside">
19124 <strong>Shown in exported artifacts</strong>
19125 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.
19126 </div>
19127 </div>
19128 </div>
19129
19130 <div class="section section-spacer-top">
19131 <div class="output-field-row">
19132 <div class="field">
19133 <label for="report_header_footer">Report header / footer</label>
19134 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19135 <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>
19136 </div>
19137 <div class="output-field-aside">
19138 <strong>Page-level identification</strong>
19139 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.
19140 </div>
19141 </div>
19142 </div>
19143
19144 <div class="wizard-actions">
19145 <div class="left">
19146 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19147 </div>
19148 <div class="right">
19149 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19150 </div>
19151 </div>
19152 </div>
19153
19154 <div class="wizard-step" data-step="4">
19155 <div class="section">
19156 <div class="section-kicker">Step 4</div>
19157 <h2>Review selections and run</h2>
19158 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19159 <div class="review-grid">
19160 <div class="review-card highlight">
19161 <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>
19162 <ul id="review-scan-summary"></ul>
19163 </div>
19164 <div class="review-card highlight">
19165 <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>
19166 <ul id="review-count-summary"></ul>
19167 </div>
19168 <div class="review-card">
19169 <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>
19170 <ul id="review-artifact-summary"></ul>
19171 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19172 </div>
19173 <div class="review-card">
19174 <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>
19175 <ul id="review-preview-summary"></ul>
19176 </div>
19177 </div>
19178 </div>
19179
19180 <div class="wizard-actions">
19181 <div class="left">
19182 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19183 </div>
19184 <div class="right">
19185 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19186 </div>
19187 </div>
19188 </div>
19189 {% if server_mode %}
19190 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19191 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19192 {% endif %}
19193 </form>
19194 </div>
19195 </section>
19196 </div>
19197 </div>
19198
19199 <script nonce="{{ csp_nonce }}">
19200 (function () {
19201 function startScanPhase() {
19202 var phaseEl = document.getElementById("scan-phase");
19203 if (!phaseEl) return;
19204 var phases = [
19205 "Discovering files...",
19206 "Decoding file encodings...",
19207 "Detecting languages...",
19208 "Analyzing source lines...",
19209 "Applying counting policies...",
19210 "Aggregating results...",
19211 "Rendering report..."
19212 ];
19213 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19214 var i = 0;
19215 function next() {
19216 phaseEl.style.opacity = "0";
19217 setTimeout(function () {
19218 phaseEl.textContent = phases[i];
19219 phaseEl.style.opacity = "0.85";
19220 var delay = durations[i] || 1800;
19221 i++;
19222 if (i < phases.length) { setTimeout(next, delay); }
19223 }, 200);
19224 }
19225 next();
19226 }
19227
19228 var form = document.getElementById("analyze-form");
19229 var loading = document.getElementById("loading");
19230 var submitButton = document.getElementById("submit-button");
19231 var pathInput = document.getElementById("path");
19232 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19233 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19234 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19235 var outputDirInput = document.getElementById("output_dir");
19236 var reportTitleInput = document.getElementById("report_title");
19237 var previewPanel = document.getElementById("preview-panel");
19238 var refreshButton = document.getElementById("refresh-preview");
19239 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19240 var useSamplePath = document.getElementById("use-sample-path");
19241 var useDefaultOutput = document.getElementById("use-default-output");
19242 var browsePath = document.getElementById("browse-path");
19243 var browseOutputDir = document.getElementById("browse-output-dir");
19244 var browseCoverage = document.getElementById("browse-coverage");
19245 var coverageInput = document.getElementById("coverage_file");
19246 var covScanStatus = document.getElementById("cov-scan-status");
19247 var coverageSuggestTimer = null;
19248 var covAutoFilled = false;
19249 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19250
19251 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19252 (function() {
19253 var ids = ["path", "output_dir"];
19254 ids.forEach(function(id) {
19255 var el = document.getElementById(id);
19256 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19257 });
19258 }());
19259 function fmtBytes(b) {
19260 b = Number(b) || 0;
19261 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19262 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19263 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19264 return b + ' B';
19265 }
19266 var themeToggle = document.getElementById("theme-toggle");
19267
19268 function showBannerToast(msg, isError, opts) {
19269 opts = opts || {};
19270 var t = document.createElement('div');
19271 t.className = isError ? 'toast-error' : 'toast-success';
19272 var topPos = opts.top ? '80px' : null;
19273 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19274 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19275 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19276 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19277 if (opts.icon) {
19278 var inner = document.createElement('span');
19279 inner.innerHTML = opts.icon + ' ';
19280 t.appendChild(inner);
19281 }
19282 t.appendChild(document.createTextNode(msg));
19283 document.body.appendChild(t);
19284 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19285 }
19286 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19287 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19288 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19289 var scanPreset = document.getElementById("scan_preset");
19290 var artifactPreset = document.getElementById("artifact_preset");
19291 var includeGlobsInput = document.getElementById("include_globs");
19292 var excludeGlobsInput = document.getElementById("exclude_globs");
19293
19294 // Include globs scope badge — updates reactively as the user types.
19295 (function() {
19296 var badge = document.getElementById("include-scope-badge");
19297 if (!badge || !includeGlobsInput) return;
19298 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> ';
19299 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> ';
19300 function update() {
19301 var val = includeGlobsInput.value.trim();
19302 if (!val) {
19303 badge.className = "include-scope-badge scope-all";
19304 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19305 } else {
19306 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19307 badge.className = "include-scope-badge scope-narrow";
19308 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19309 }
19310 }
19311 includeGlobsInput.addEventListener("input", update);
19312 update();
19313 }());
19314
19315 // Quick-exclude chips — append pattern to exclude_globs textarea.
19316 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19317 chip.addEventListener("click", function() {
19318 var pattern = chip.getAttribute("data-pattern") || "";
19319 if (!pattern || !excludeGlobsInput) return;
19320 var current = excludeGlobsInput.value.trim();
19321 // For the "skip all" chip, replace any existing dep patterns cleanly.
19322 var patterns = pattern.split("\n");
19323 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19324 var added = false;
19325 patterns.forEach(function(p) {
19326 p = p.trim();
19327 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19328 });
19329 if (added) {
19330 excludeGlobsInput.value = lines.join("\n");
19331 excludeGlobsInput.dispatchEvent(new Event("input"));
19332 }
19333 chip.classList.add("active");
19334 });
19335 });
19336
19337 var liveReportTitle = document.getElementById("live-report-title");
19338 var navProjectPill = document.getElementById("nav-project-pill");
19339 var navProjectTitle = document.getElementById("nav-project-title");
19340 var reportTitlePreview = null;
19341 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19342 var wizardProgressValue = document.getElementById("wizard-progress-value");
19343 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19344 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19345 var reportTitleTouched = false;
19346 var currentStep = 1;
19347 var previewTimer = null;
19348 var _previewGen = 0;
19349 // True while the scope preview (local) / project upload (server mode) is in
19350 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19351 // user can't advance past a project whose scope/upload isn't ready yet.
19352 var previewLoading = false;
19353 // Set when the current preview reports multiple independent git repos under
19354 // the selected root. Advancing past step 1 is blocked until the user ticks
19355 // the acknowledgement checkbox (or re-selects a single repository).
19356 var multiRepoBlocked = false;
19357 function step1ForwardBlocked() {
19358 return previewLoading || multiRepoBlocked;
19359 }
19360 function refreshStep1Gate() {
19361 var nextBtn = document.getElementById("step1-next");
19362 if (nextBtn) {
19363 var blocked = step1ForwardBlocked();
19364 nextBtn.classList.toggle("is-blocked", blocked);
19365 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19366 }
19367 }
19368 function setPreviewLoading(loading) {
19369 previewLoading = !!loading;
19370 var gate = document.getElementById("preview-gate-status");
19371 refreshStep1Gate();
19372 if (gate) {
19373 var txt = gate.querySelector(".preview-gate-text");
19374 if (txt) txt.textContent = SERVER_MODE
19375 ? "Uploading & scanning project…"
19376 : "Scanning project scope…";
19377 gate.style.display = previewLoading ? "flex" : "none";
19378 }
19379 }
19380 // Info button on the gate: scroll up to the live scope preview so the user
19381 // can see exactly what is being scanned (elapsed time + rotating status).
19382 var previewGateInfo = document.getElementById("preview-gate-info");
19383 if (previewGateInfo) {
19384 previewGateInfo.addEventListener("click", function () {
19385 var target = document.getElementById("preview-panel");
19386 if (!target) return;
19387 target.scrollIntoView({ behavior: "smooth", block: "center" });
19388 target.classList.add("preview-panel-flash");
19389 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19390 });
19391 }
19392 var quickScanBtn = document.getElementById("quick-scan-btn");
19393
19394 function dismissAnalysisModal() {
19395 if (loading) loading.classList.remove("active");
19396 document.body.classList.remove("modal-open");
19397 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19398 var el = document.getElementById(id);
19399 if (el) el.classList.add("hidden");
19400 });
19401 var cancelBtn = document.getElementById("lc-cancel-btn");
19402 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19403 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19404 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19405 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19406 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");}
19407 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19408 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19409 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19410 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19411 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19412 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19413 }
19414
19415 var lcDismissBtn = document.getElementById("lc-dismiss");
19416 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19417
19418 // When the browser restores this page from bfcache (Back button after navigating to results),
19419 // the loading overlay would still be showing its active state. Dismiss it immediately.
19420 window.addEventListener("pageshow", function(e) {
19421 if (e.persisted) { dismissAnalysisModal(); }
19422 });
19423
19424 function startAsyncAnalysis(formData) {
19425 var gitRepo = (formData.get("git_repo") || "").toString();
19426 var gitRef = (formData.get("git_ref") || "").toString();
19427 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19428 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19429
19430 var pathEl = document.getElementById("lc-path-text");
19431 if (pathEl) pathEl.textContent = displayPath;
19432
19433 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19434 var el = document.getElementById(id);
19435 if (el) el.classList.add("hidden");
19436 });
19437 var cancelBtn = document.getElementById("lc-cancel-btn");
19438 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19439 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19440 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19441 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19442 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19443 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19444 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19445 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");}
19446 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19447
19448 if (loading) loading.classList.add("active");
19449 document.body.classList.add("modal-open");
19450
19451 var startTime = Date.now();
19452 var elapsedTimer = setInterval(function() {
19453 var s = Math.floor((Date.now() - startTime) / 1000);
19454 var el = document.getElementById("lc-elapsed");
19455 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19456 }, 1000);
19457
19458 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19459
19460 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();}
19461
19462 var PHASE_DESC = {
19463 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19464 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19465 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19466 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19467 'Done': 'Analysis complete \u2014 loading your results\u2026',
19468 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19469 };
19470 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19471 function lcSetPhase(txt) {
19472 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19473 var desc = document.getElementById("lc-stage-desc");
19474 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19475 var step = PHASE_STEP[txt] || 1;
19476 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");}
19477 }
19478
19479 function lcShowCancelled() {
19480 clearInterval(elapsedTimer);
19481 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19482 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19483 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19484 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19485 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19486 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19487 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19488 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19489 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19490 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19491 }
19492
19493 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19494 if (lcCancelBtn) {
19495 lcCancelBtn.onclick = function() {
19496 if (!activeWaitId) { dismissAnalysisModal(); return; }
19497 lcCancelBtn.disabled = true;
19498 lcCancelBtn.textContent = "Cancelling\u2026";
19499 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19500 .then(function() { lcShowCancelled(); })
19501 .catch(function() { lcShowCancelled(); });
19502 };
19503 }
19504
19505 function lcShowError(msg) {
19506 clearInterval(elapsedTimer);
19507 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19508 lcSetPhase("Failed");
19509 var msgEl = document.getElementById("lc-err-msg");
19510 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19511 var errEl = document.getElementById("lc-err");
19512 var actEl = document.getElementById("lc-actions");
19513 if (errEl) errEl.classList.remove("hidden");
19514 if (actEl) actEl.classList.remove("hidden");
19515 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19516 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19517 }
19518
19519 function lcPoll(waitId) {
19520 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19521 .then(function(r) {
19522 if (!r.ok) throw new Error("HTTP " + r.status);
19523 return r.json();
19524 })
19525 .then(function(data) {
19526 pollRetries = 0;
19527 if (data.state === "complete") {
19528 clearInterval(elapsedTimer);
19529 lcSetPhase("Done");
19530 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19531 } else if (data.state === "failed") {
19532 lcShowError(data.message);
19533 } else if (data.state === "cancelled") {
19534 lcShowCancelled();
19535 } else {
19536 var s = Math.floor((Date.now() - startTime) / 1000);
19537 if (s > 90 && !warnShown) {
19538 warnShown = true;
19539 var w = document.getElementById("lc-warn");
19540 if (w) w.classList.remove("hidden");
19541 }
19542 lcSetPhase(data.phase || "Running");
19543 var fd = data.files_done || 0, ft = data.files_total || 0;
19544 if (ft > 0) {
19545 var card = document.getElementById("lc-files-card");
19546 if (card) card.classList.remove("hidden");
19547 var el = document.getElementById("lc-files");
19548 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19549 var now = Date.now();
19550 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19551 if (fdelta > 0 && tdelta > 0.4) {
19552 var fps = Math.round(fdelta / tdelta);
19553 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19554 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19555 }
19556 lastFd = fd; lastFdTime = now;
19557 }
19558 setTimeout(function() { lcPoll(waitId); }, 1500);
19559 }
19560 })
19561 .catch(function() {
19562 pollRetries++;
19563 if (pollRetries >= 5) {
19564 lcShowError("Lost connection to server. Reload to check status.");
19565 } else {
19566 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19567 }
19568 });
19569 }
19570
19571 var params = new URLSearchParams(formData);
19572 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19573 .then(function(r) {
19574 var waitId = r.headers.get("x-wait-id");
19575 if (!waitId) { window.location.href = "/scan"; return; }
19576 activeWaitId = waitId;
19577 setTimeout(function() { lcPoll(waitId); }, 1500);
19578 })
19579 .catch(function(err) {
19580 lcShowError("Could not reach server: " + (err.message || err));
19581 });
19582 }
19583
19584 if (quickScanBtn) {
19585 quickScanBtn.addEventListener("click", function () {
19586 var pathVal = pathInput ? pathInput.value.trim() : "";
19587 if (!pathVal) {
19588 alert("Please enter or browse to a project path first.");
19589 return;
19590 }
19591 quickScanBtn.disabled = true;
19592 quickScanBtn.textContent = "Scanning...";
19593 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19594 startAsyncAnalysis(new FormData(form));
19595 });
19596 }
19597
19598 var mixedPolicyInfo = {
19599 code_only: {
19600 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.",
19601 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'
19602 },
19603 code_and_comment: {
19604 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.",
19605 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'
19606 },
19607 comment_only: {
19608 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.",
19609 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'
19610 },
19611 separate_mixed_category: {
19612 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.",
19613 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'
19614 }
19615 };
19616
19617 var scanPresetInfo = {
19618 balanced: {
19619 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.",
19620 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19621 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19622 note: "Best when you want a stable local overview before making deeper adjustments.",
19623 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19624 },
19625 code_focused: {
19626 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19627 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19628 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19629 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19630 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19631 },
19632 comment_audit: {
19633 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19634 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19635 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19636 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19637 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19638 },
19639 deep_review: {
19640 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19641 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19642 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19643 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19644 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19645 }
19646 };
19647
19648 var artifactPresetInfo = {
19649 review: {
19650 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19651 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19652 example: "Ideal for a quick local review before sharing results."
19653 },
19654 full: {
19655 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19656 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19657 example: "Use when producing a deliverable or storing a snapshot for future comparison."
19658 },
19659 html_only: {
19660 description: "Standalone HTML report only. No PDF generation, no data files.",
19661 chips: ["HTML only"],
19662 example: "Fastest option when you only need to open the report in a browser."
19663 },
19664 machine: {
19665 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19666 chips: ["JSON", "CSV", "no HTML", "no PDF"],
19667 example: "Use in CI to capture metrics without generating visual reports."
19668 }
19669 };
19670
19671 function applyArtifactPreset() {
19672 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19673 if (!info) return;
19674 var descEl = document.getElementById("artifact-preset-description");
19675 var exampleEl = document.getElementById("artifact-preset-example");
19676 if (descEl) descEl.textContent = info.description;
19677 if (exampleEl) exampleEl.textContent = info.example;
19678 renderPresetChips("artifact-preset-summary", info.chips);
19679 }
19680
19681 function applyTheme(theme) {
19682 if (theme === "dark") document.body.classList.add("dark-theme");
19683 else document.body.classList.remove("dark-theme");
19684 }
19685
19686 function loadSavedTheme() {
19687 var saved = null;
19688 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19689 applyTheme(saved === "dark" ? "dark" : "light");
19690 }
19691
19692 function updateScrollProgress() {
19693 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19694 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19695 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19696 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19697 var step = Math.min(Math.max(currentStep, 1), 4);
19698 var base = stepBase[step];
19699 var end = stepEnd[step];
19700
19701 var scrollFrac = 0;
19702 var activePanel = document.querySelector(".wizard-step.active");
19703 if (activePanel) {
19704 var scrollTop = window.scrollY || window.pageYOffset || 0;
19705 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19706 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19707 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19708 var scrolled = scrollTop + viewH - panelTop;
19709 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19710 }
19711
19712 var percent = Math.round(base + (end - base) * scrollFrac);
19713 percent = Math.min(end, Math.max(base, percent));
19714 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19715 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19716 }
19717
19718 function updateWizardProgress() {
19719 updateScrollProgress();
19720 }
19721
19722 var stepDescriptions = [
19723 "Choose a project folder, apply scope filters, and preview which files will be counted.",
19724 "Configure how mixed code-plus-comment lines and docstrings are classified.",
19725 "Pick your output formats, scan preset, and where reports are saved.",
19726 "Review all settings and launch the analysis."
19727 ];
19728
19729 function updateStepNav(step) {
19730 var infoLabel = document.getElementById("step-nav-info-label");
19731 var infoDesc = document.getElementById("step-nav-info-desc");
19732 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19733 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
19734 }
19735
19736 function updateSidebarSummary() {
19737 var sumPath = document.getElementById("sum-path");
19738 var sumPreset = document.getElementById("sum-preset");
19739 var sumOutput = document.getElementById("sum-output");
19740 var sidebarSummary = document.getElementById("sidebar-summary");
19741 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19742 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
19743 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19744 if (sumPath) sumPath.textContent = pathVal || "\u2014";
19745 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19746 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19747 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19748 }
19749
19750 function setStep(step, pushHistory) {
19751 currentStep = step;
19752 stepPanels.forEach(function (panel) {
19753 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19754 });
19755 stepButtons.forEach(function (button) {
19756 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19757 });
19758 var layoutEl = document.querySelector(".layout");
19759 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19760 updateWizardProgress();
19761 updateStepNav(step);
19762 stepButtons.forEach(function(btn) {
19763 var t = Number(btn.getAttribute("data-step-target"));
19764 btn.classList.toggle("done", t < step);
19765 });
19766 updateSidebarSummary();
19767
19768 if (pushHistory !== false) {
19769 try {
19770 history.pushState({ wizardStep: step }, "", "#step" + step);
19771 } catch (e) {}
19772 }
19773
19774 window.scrollTo({ top: 0, behavior: "instant" });
19775 }
19776
19777 window.addEventListener("popstate", function (e) {
19778 if (e.state && e.state.wizardStep) {
19779 setStep(e.state.wizardStep, false);
19780 } else {
19781 var hashMatch = location.hash.match(/^#step([1-4])$/);
19782 if (hashMatch) setStep(Number(hashMatch[1]), false);
19783 }
19784 });
19785
19786 function inferTitleFromPath(value) {
19787 if (!value) return "project";
19788 var cleaned = value.replace(/[\/\\]+$/, "");
19789 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19790 return parts.length ? parts[parts.length - 1] : value;
19791 }
19792
19793 function updateReportTitleFromPath() {
19794 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19795 if (!reportTitleTouched) {
19796 reportTitleInput.value = inferred;
19797 }
19798 var title = reportTitleInput.value || inferred;
19799 if (liveReportTitle) liveReportTitle.textContent = title;
19800 if (reportTitlePreview) reportTitlePreview.textContent = title;
19801 document.title = "OxideSLOC | " + title;
19802
19803 var projectPath = (pathInput.value || "").trim();
19804 if (navProjectPill && navProjectTitle) {
19805 if (projectPath.length > 0) {
19806 navProjectTitle.textContent = inferred;
19807 navProjectPill.classList.add("visible");
19808 } else {
19809 navProjectTitle.textContent = "";
19810 navProjectPill.classList.remove("visible");
19811 }
19812 }
19813 }
19814
19815 function updateMixedPolicyUI() {
19816 var key = mixedLinePolicy.value || "code_only";
19817 var info = mixedPolicyInfo[key];
19818 document.getElementById("mixed-policy-description").textContent = info.description;
19819 document.getElementById("mixed-policy-example").textContent = info.example;
19820 }
19821
19822 function updatePythonDocstringUI() {
19823 var checked = !!pythonDocstrings.checked;
19824 document.getElementById("python-docstring-example").textContent = checked
19825 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
19826 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
19827 document.getElementById("python-docstring-live-help").textContent = checked
19828 ? "Enabled: docstrings contribute to comment-style totals."
19829 : "Disabled: docstrings are not counted as comment content.";
19830 }
19831
19832 function renderPresetChips(targetId, chips) {
19833 var target = document.getElementById(targetId);
19834 if (!target) return;
19835 target.innerHTML = (chips || []).map(function (chip) {
19836 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19837 }).join('');
19838 }
19839
19840 function updatePresetDescriptions() {
19841 var scanInfo = scanPresetInfo[scanPreset.value];
19842 if (!scanInfo) return;
19843 document.getElementById("scan-preset-description").textContent = scanInfo.description;
19844 document.getElementById("scan-preset-example").textContent = scanInfo.example;
19845 document.getElementById("scan-preset-note").textContent = scanInfo.note;
19846 renderPresetChips("scan-preset-summary", scanInfo.chips);
19847 }
19848
19849 function applyScanPreset() {
19850 var info = scanPresetInfo[scanPreset.value];
19851 if (!info || !info.apply) return;
19852 mixedLinePolicy.value = info.apply.mixed;
19853 pythonDocstrings.checked = !!info.apply.docstrings;
19854 document.getElementById("generated_file_detection").value = info.apply.generated;
19855 document.getElementById("minified_file_detection").value = info.apply.minified;
19856 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19857 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19858 document.getElementById("binary_file_behavior").value = info.apply.binary;
19859 updateMixedPolicyUI();
19860 updatePythonDocstringUI();
19861 }
19862
19863 function updateReview() {
19864 var scanSummary = document.getElementById("review-scan-summary");
19865 var countSummary = document.getElementById("review-count-summary");
19866 var artifactSummary = document.getElementById("review-artifact-summary");
19867 var outputSummary = document.getElementById("review-output-summary");
19868 var previewSummary = document.getElementById("review-preview-summary");
19869 var readinessSummary = document.getElementById("review-readiness-summary");
19870 var includeText = document.getElementById("include_globs").value.trim();
19871 var excludeText = document.getElementById("exclude_globs").value.trim();
19872 var sidePathPreview = document.getElementById("side-path-preview");
19873 var sideOutputPreview = document.getElementById("side-output-preview");
19874 var sideTitlePreview = document.getElementById("side-title-preview");
19875
19876 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19877 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19878 if (sideTitlePreview) {
19879 var rt = document.getElementById("report_title");
19880 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19881 }
19882
19883 scanSummary.innerHTML = ""
19884 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19885 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19886 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19887
19888 countSummary.innerHTML = ""
19889 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19890 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19891 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19892 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19893 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19894 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19895 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19896 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19897
19898 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19899
19900 outputSummary.innerHTML = ""
19901 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19902 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19903
19904 if (previewSummary) {
19905 if (GIT_MODE) {
19906 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>';
19907 } else {
19908 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19909 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19910 var statMap = {};
19911 statButtons.forEach(function (button) {
19912 var valueNode = button.querySelector('.scope-stat-value');
19913 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19914 });
19915 previewSummary.innerHTML = ''
19916 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19917 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19918 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19919 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19920 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19921 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19922
19923 if (readinessSummary) {
19924 readinessSummary.innerHTML = ''
19925 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19926 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19927 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19928 }
19929 } // end else (non-GIT_MODE)
19930 }
19931 }
19932
19933 function escapeHtml(value) {
19934 return String(value)
19935 .replace(/&/g, "&")
19936 .replace(/</g, "<")
19937 .replace(/>/g, ">")
19938 .replace(/"/g, """)
19939 .replace(/'/g, "'");
19940 }
19941
19942 function isPythonVisible() {
19943 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19944 }
19945
19946 function syncPythonVisibility() {
19947 var html = previewPanel.textContent || "";
19948 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19949 pythonWraps.forEach(function (node) {
19950 node.classList.toggle("hidden", !hasPython);
19951 });
19952 }
19953
19954 function attachPreviewInteractions() {
19955 // Multiple-repository caution banner: gate step 1 until acknowledged, and
19956 // let each listed repo be picked as the scan root with one click.
19957 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
19958 if (multiRepoBanner) {
19959 multiRepoBlocked = true;
19960 refreshStep1Gate();
19961 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
19962 if (ackBox) {
19963 ackBox.addEventListener("change", function () {
19964 multiRepoBlocked = !ackBox.checked;
19965 refreshStep1Gate();
19966 });
19967 }
19968 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
19969 repoButtons.forEach(function (btn) {
19970 btn.addEventListener("click", function () {
19971 var repoPath = btn.getAttribute("data-repo-path") || "";
19972 if (!repoPath || !pathInput) return;
19973 pathInput.value = repoPath;
19974 scrollInputToEnd(pathInput);
19975 updateReportTitleFromPath();
19976 autoSetOutputDir(repoPath);
19977 fetchProjectHistory(repoPath);
19978 loadPreview();
19979 updateReview();
19980 });
19981 });
19982 }
19983 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19984 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19985 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19986 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19987 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19988 var searchInput = previewPanel.querySelector("#explorer-search");
19989 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19990 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19991 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19992 var activeFilter = "all";
19993 var activeLanguage = "";
19994 var searchTerm = "";
19995 var currentSortKey = null;
19996 var currentSortOrder = "asc";
19997 var childRows = {};
19998
19999 rows.forEach(function (row) {
20000 var parentId = row.getAttribute("data-parent-id") || "";
20001 var rowId = row.getAttribute("data-row-id") || "";
20002 if (!childRows[parentId]) childRows[parentId] = [];
20003 childRows[parentId].push(rowId);
20004 });
20005
20006 function rowById(id) {
20007 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20008 }
20009
20010 function hasCollapsedAncestor(row) {
20011 var parentId = row.getAttribute("data-parent-id");
20012 while (parentId) {
20013 var parent = rowById(parentId);
20014 if (!parent) break;
20015 if (parent.getAttribute("data-expanded") === "false") return true;
20016 parentId = parent.getAttribute("data-parent-id");
20017 }
20018 return false;
20019 }
20020
20021 function updateToggleGlyph(row) {
20022 var toggle = row.querySelector(".tree-toggle");
20023 if (!toggle) return;
20024 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20025 }
20026
20027 function rowSortValue(row, key) {
20028 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20029 }
20030
20031 function updateSortButtons() {
20032 sortButtons.forEach(function (button) {
20033 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20034 var indicator = button.querySelector(".tree-sort-indicator");
20035 button.classList.toggle("active", isActive);
20036 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20037 if (indicator) {
20038 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20039 }
20040 });
20041 }
20042
20043 function sortSiblingRows() {
20044 if (!treeContainer) {
20045 updateSortButtons();
20046 return;
20047 }
20048
20049 var rowMap = {};
20050 var childrenMap = {};
20051 rows.forEach(function (row) {
20052 var rowId = row.getAttribute("data-row-id");
20053 var parentId = row.getAttribute("data-parent-id") || "";
20054 rowMap[rowId] = row;
20055 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20056 childrenMap[parentId].push(rowId);
20057 });
20058
20059 Object.keys(childrenMap).forEach(function (parentId) {
20060 if (!parentId) return;
20061 childrenMap[parentId].sort(function (a, b) {
20062 var rowA = rowMap[a];
20063 var rowB = rowMap[b];
20064 if (!currentSortKey) {
20065 return Number(a) - Number(b);
20066 }
20067 var valueA = rowSortValue(rowA, currentSortKey);
20068 var valueB = rowSortValue(rowB, currentSortKey);
20069 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20070 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20071 var fallbackA = rowSortValue(rowA, "name");
20072 var fallbackB = rowSortValue(rowB, "name");
20073 if (fallbackA < fallbackB) return -1;
20074 if (fallbackA > fallbackB) return 1;
20075 return Number(a) - Number(b);
20076 });
20077 });
20078
20079 var orderedIds = [];
20080 function pushChildren(parentId) {
20081 (childrenMap[parentId] || []).forEach(function (childId) {
20082 orderedIds.push(childId);
20083 pushChildren(childId);
20084 });
20085 }
20086
20087 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20088 orderedIds.push(topId);
20089 pushChildren(topId);
20090 });
20091
20092 orderedIds.forEach(function (id) {
20093 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20094 });
20095 updateSortButtons();
20096 }
20097
20098 function updateLanguageButtons() {
20099 languageButtons.forEach(function (button) {
20100 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20101 var isActive = languageValue === activeLanguage;
20102 button.classList.toggle("active", isActive);
20103 });
20104 }
20105
20106 function rowSelfMatches(row) {
20107 var kind = row.getAttribute("data-kind");
20108 var status = row.getAttribute("data-status");
20109 var language = (row.getAttribute("data-language") || "").toLowerCase();
20110 var name = row.getAttribute("data-name-lower") || "";
20111 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20112 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20113 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20114 var passesLanguage = !activeLanguage || language === activeLanguage;
20115 return passesFilter && passesSearch && passesLanguage;
20116 }
20117
20118 function hasMatchingDescendant(rowId) {
20119 return (childRows[rowId] || []).some(function (childId) {
20120 var childRow = rowById(childId);
20121 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20122 });
20123 }
20124
20125 function rowMatches(row) {
20126 if (rowSelfMatches(row)) return true;
20127 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20128 }
20129
20130 function resetViewState() {
20131 activeFilter = "all";
20132 activeLanguage = "";
20133 searchTerm = "";
20134 currentSortKey = null;
20135 currentSortOrder = "asc";
20136 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20137 if (searchInput) searchInput.value = "";
20138 if (filterSelect) filterSelect.value = "all";
20139 updateLanguageButtons();
20140 }
20141
20142 function applyVisibility() {
20143 rows.forEach(function (row) {
20144 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20145 row.classList.toggle("hidden-by-filter", !visible);
20146 row.style.display = visible ? "grid" : "none";
20147 });
20148 buttons.forEach(function (button) {
20149 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20150 });
20151 if (filterSelect) filterSelect.value = activeFilter;
20152 }
20153
20154 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20155 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20156 var originalStats = {};
20157 buttons.forEach(function (btn) {
20158 var f = btn.getAttribute('data-filter');
20159 var v = btn.querySelector('.scope-stat-value');
20160 if (f && v) originalStats[f] = v.textContent;
20161 });
20162
20163 function applySubmoduleStats(statsJson) {
20164 try {
20165 var s = JSON.parse(statsJson);
20166 buttons.forEach(function (btn) {
20167 var f = btn.getAttribute('data-filter');
20168 var v = btn.querySelector('.scope-stat-value');
20169 if (!v) return;
20170 if (f === 'dir') v.textContent = s.dirs;
20171 else if (f === 'file') v.textContent = s.files;
20172 else if (f === 'supported') v.textContent = s.supported;
20173 else if (f === 'skipped') v.textContent = s.skipped;
20174 else if (f === 'unsupported') v.textContent = s.unsupported;
20175 });
20176 } catch (e) {}
20177 }
20178
20179 function restoreBaseRepoStats() {
20180 buttons.forEach(function (btn) {
20181 var f = btn.getAttribute('data-filter');
20182 var v = btn.querySelector('.scope-stat-value');
20183 if (v && originalStats[f]) v.textContent = originalStats[f];
20184 });
20185 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20186 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20187 }
20188
20189 submoduleChips.forEach(function (chip) {
20190 chip.addEventListener('click', function () {
20191 var statsJson = chip.getAttribute('data-sub-stats');
20192 if (!statsJson) return;
20193 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20194 chip.classList.add('active');
20195 applySubmoduleStats(statsJson);
20196 if (baseRepoBtn) baseRepoBtn.style.display = '';
20197 });
20198 });
20199
20200 if (baseRepoBtn) {
20201 baseRepoBtn.addEventListener('click', function () {
20202 restoreBaseRepoStats();
20203 resetViewState();
20204 sortSiblingRows();
20205 applyVisibility();
20206 });
20207 }
20208
20209 buttons.forEach(function (button) {
20210 button.addEventListener("click", function () {
20211 var filterValue = button.getAttribute("data-filter") || "all";
20212 if (filterValue === "reset-view") {
20213 restoreBaseRepoStats();
20214 resetViewState();
20215 sortSiblingRows();
20216 applyVisibility();
20217 return;
20218 }
20219 activeFilter = filterValue;
20220 applyVisibility();
20221 });
20222 });
20223
20224 rows.forEach(function (row) {
20225 updateToggleGlyph(row);
20226 var toggle = row.querySelector(".tree-toggle");
20227 if (toggle) {
20228 toggle.addEventListener("click", function () {
20229 var expanded = row.getAttribute("data-expanded") !== "false";
20230 row.setAttribute("data-expanded", expanded ? "false" : "true");
20231 updateToggleGlyph(row);
20232 applyVisibility();
20233 });
20234 }
20235 });
20236
20237 actionButtons.forEach(function (button) {
20238 button.addEventListener("click", function () {
20239 var action = button.getAttribute("data-explorer-action");
20240 if (action === "expand-all") {
20241 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20242 } else if (action === "collapse-all") {
20243 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20244 } else if (action === "clear-filters") {
20245 resetViewState();
20246 }
20247 sortSiblingRows();
20248 applyVisibility();
20249 });
20250 });
20251
20252 if (filterSelect) {
20253 filterSelect.addEventListener("change", function () {
20254 activeFilter = filterSelect.value || "all";
20255 applyVisibility();
20256 });
20257 }
20258
20259 languageButtons.forEach(function (button) {
20260 button.addEventListener("click", function () {
20261 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20262 updateLanguageButtons();
20263 applyVisibility();
20264 });
20265 });
20266
20267 sortButtons.forEach(function (button) {
20268 button.addEventListener("click", function () {
20269 var sortKey = button.getAttribute("data-sort-key");
20270 if (currentSortKey === sortKey) {
20271 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20272 } else {
20273 currentSortKey = sortKey;
20274 currentSortOrder = "asc";
20275 }
20276 sortSiblingRows();
20277 applyVisibility();
20278 });
20279 });
20280
20281 if (searchInput) {
20282 searchInput.addEventListener("input", function () {
20283 searchTerm = searchInput.value.trim().toLowerCase();
20284 applyVisibility();
20285 });
20286 }
20287
20288 updateLanguageButtons();
20289 sortSiblingRows();
20290 applyVisibility();
20291 }
20292
20293 function loadPreview() {
20294 if (!previewPanel || !pathInput) return;
20295 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20296 multiRepoBlocked = false;
20297 refreshStep1Gate();
20298 if (GIT_MODE) {
20299 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>';
20300 setPreviewLoading(false);
20301 return;
20302 }
20303 var path = pathInput.value.trim();
20304 var zeroWarn = document.getElementById('zero-files-warning');
20305 if (!path) {
20306 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20307 if (zeroWarn) zeroWarn.style.display = 'none';
20308 setPreviewLoading(false);
20309 return;
20310 }
20311 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20312 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20313 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20314 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20315 var myGen = ++_previewGen;
20316 var _prevMsgs = [
20317 'Scanning directory structure\u2026',
20318 'Detecting file types\u2026',
20319 'Applying include / exclude filters\u2026',
20320 'Estimating file counts\u2026',
20321 'Building scope preview\u2026',
20322 'Almost there\u2026'
20323 ];
20324 var _prevMsgIdx = 0;
20325 var _prevStart = Date.now();
20326 previewPanel.innerHTML =
20327 '<div class="preview-loading">' +
20328 '<div class="preview-spinner"></div>' +
20329 '<div class="preview-loading-text">' +
20330 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20331 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20332 '</div></div>';
20333 var _sizeTextEl = document.getElementById('project-size-text');
20334 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20335 window._previewInterval = setInterval(function() {
20336 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20337 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20338 var ml = document.getElementById('plm');
20339 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20340 }, 1500);
20341 window._previewElapsedTimer = setInterval(function() {
20342 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20343 var el = document.getElementById('ple');
20344 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20345 }, 1000);
20346 setPreviewLoading(true);
20347 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20348 + "&include_globs=" + encodeURIComponent(includeValue)
20349 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20350 fetch(previewUrl)
20351 .then(function (response) { return response.text(); })
20352 .then(function (html) {
20353 if (myGen !== _previewGen) return;
20354 clearInterval(window._previewInterval); window._previewInterval = null;
20355 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20356 setPreviewLoading(false);
20357 previewPanel.innerHTML = html;
20358 attachPreviewInteractions();
20359 syncPythonVisibility();
20360 updateReview();
20361 setTimeout(collapseLanguagePills, 50);
20362 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20363 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20364 var sizeText = document.getElementById('project-size-text');
20365 var sizeBtn = document.getElementById('project-size-btn');
20366 // In server mode with upload sizes available, keep the compressed/original pair.
20367 if (SERVER_MODE && window._lastUploadSizes) {
20368 var us = window._lastUploadSizes;
20369 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20370 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20371 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20372 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20373 } else if (sizeText && projectSize) {
20374 sizeText.textContent = 'Project size: ' + projectSize;
20375 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20376 } else if (sizeText) {
20377 sizeText.textContent = 'Project size: \u2014';
20378 }
20379 if (zeroWarn) {
20380 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20381 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20382 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20383 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20384 if (supportedCount === 0 && fileCount > 0) {
20385 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).';
20386 zeroWarn.style.display = '';
20387 } else {
20388 zeroWarn.style.display = 'none';
20389 }
20390 }
20391 })
20392 .catch(function (err) {
20393 if (myGen !== _previewGen) return;
20394 clearInterval(window._previewInterval); window._previewInterval = null;
20395 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20396 setPreviewLoading(false);
20397 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20398 });
20399 }
20400
20401 function pickDirectory(targetInput, kind) {
20402 if (!targetInput) {
20403 showBannerToast("Directory picker: input element not found.", true);
20404 return;
20405 }
20406 if (SERVER_MODE) {
20407 if (kind === 'output') {
20408 showBannerToast(
20409 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20410 false,
20411 { top: true, icon: '\u{1F4C1}' }
20412 );
20413 return;
20414 }
20415 var inputEl = kind === 'coverage'
20416 ? document.getElementById('cov-upload-input')
20417 : document.getElementById('dir-upload-input');
20418 if (!inputEl) return;
20419 inputEl.onchange = function () {
20420 var files = inputEl.files;
20421 if (!files || files.length === 0) return;
20422 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20423 if (browseBtn) browseBtn.disabled = true;
20424
20425 function fileToBase64(file) {
20426 return new Promise(function (resolve, reject) {
20427 var reader = new FileReader();
20428 reader.onload = function () {
20429 var b64 = reader.result.split(',')[1];
20430 resolve(b64);
20431 };
20432 reader.onerror = reject;
20433 reader.readAsDataURL(file);
20434 });
20435 }
20436
20437 if (kind === 'coverage') {
20438 var f = files[0];
20439 if (previewPanel && targetInput === pathInput)
20440 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20441 fileToBase64(f).then(function (b64) {
20442 return fetch('/api/upload-file', {
20443 method: 'POST',
20444 headers: { 'Content-Type': 'application/json' },
20445 body: JSON.stringify({ filename: f.name, content: b64 })
20446 }).then(function (r) { return r.json(); });
20447 })
20448 .then(function (d) {
20449 if (d && d.tmp_path) {
20450 if (coverageInput) coverageInput.value = d.tmp_path;
20451 setCovStatus('idle');
20452 } else if (d && d.error) { showBannerToast(d.error, true); }
20453 })
20454 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20455 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20456 } else {
20457 // ── Filter to source-code files only ─────────────────────────
20458 // Binary, generated, and dependency files (node_modules, .git,
20459 // build artifacts) are skipped so they are never uploaded.
20460 var CODE_EXTS = new Set([
20461 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20462 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20463 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20464 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20465 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20466 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20467 'tf','hcl','proto','thrift','avsc','graphql','gql'
20468 ]);
20469 var codeFiles = [];
20470 for (var i = 0; i < files.length; i++) {
20471 var f = files[i];
20472 var name = f.name;
20473 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20474 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20475 codeFiles.push(f); continue;
20476 }
20477 var dot = name.lastIndexOf('.');
20478 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20479 }
20480 // Collect specific .git metadata files for server-side git detection.
20481 // These have no source extension so they are excluded by the loop above,
20482 // but the server needs them to read branch/commit/author without running git.
20483 var gitMetaFiles = [];
20484 for (var i = 0; i < files.length; i++) {
20485 var f = files[i];
20486 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20487 var gitIdx = rp.indexOf('/.git/');
20488 if (gitIdx < 0) continue;
20489 var gitRel = rp.slice(gitIdx + 1);
20490 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20491 gitRel === '.git/logs/HEAD' ||
20492 gitRel.startsWith('.git/refs/heads/') ||
20493 gitRel.startsWith('.git/refs/tags/')) {
20494 gitMetaFiles.push(f);
20495 }
20496 }
20497 var uploadFiles = codeFiles.concat(gitMetaFiles);
20498 var total = files.length;
20499 var kept = codeFiles.length;
20500 if (kept === 0) {
20501 if (previewPanel && targetInput === pathInput)
20502 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20503 if (browseBtn) browseBtn.disabled = false;
20504 inputEl.value = '';
20505 return;
20506 }
20507
20508 // ── Helper: apply upload result to UI ────────────────────────
20509 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20510 function applyUploadResult(tmpPath, sizes) {
20511 targetInput.value = tmpPath;
20512 scrollInputToEnd(targetInput);
20513 if (sizes && SERVER_MODE) {
20514 window._lastUploadSizes = sizes;
20515 // Immediately show both sizes before preview loads.
20516 var sizeText = document.getElementById('project-size-text');
20517 var sizeBtn = document.getElementById('project-size-btn');
20518 if (sizeText) {
20519 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20520 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20521 }
20522 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20523 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20524 }
20525 if (targetInput === pathInput) {
20526 updateReportTitleFromPath();
20527 autoSetOutputDir(tmpPath);
20528 fetchProjectHistory(tmpPath);
20529 loadPreview();
20530 suggestCoverageFile(tmpPath);
20531 }
20532 updateReview();
20533 if (browseBtn) browseBtn.disabled = false;
20534 inputEl.value = '';
20535 }
20536
20537 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20538 if (typeof CompressionStream !== 'undefined') {
20539 if (previewPanel && targetInput === pathInput)
20540 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20541
20542 // Build a minimal POSIX ustar tar header for a single file entry.
20543 function buildUstarHeader(filePath, fileSize) {
20544 var BLOCK = 512;
20545 var hdr = new Uint8Array(BLOCK);
20546 var enc = new TextEncoder();
20547 function wStr(off, len, s) {
20548 var b = enc.encode(s);
20549 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20550 }
20551 function wOct(off, len, val) {
20552 var s = val.toString(8);
20553 while (s.length < len - 1) s = '0' + s;
20554 wStr(off, len, s + '\0');
20555 }
20556 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20557 var name = filePath, prefix = '';
20558 if (filePath.length > 99) {
20559 var split = filePath.lastIndexOf('/', 154);
20560 if (split > 0 && filePath.length - split - 1 <= 99) {
20561 prefix = filePath.substring(0, split);
20562 name = filePath.substring(split + 1);
20563 } else { name = filePath.substring(0, 99); }
20564 }
20565 wStr(0, 100, name); // name
20566 wOct(100, 8, 0o000644); // mode
20567 wOct(108, 8, 0); // uid
20568 wOct(116, 8, 0); // gid
20569 wOct(124, 12, fileSize); // size
20570 wOct(136, 12, 0); // mtime (epoch)
20571 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20572 hdr[156] = 48; // type flag '0' = regular file
20573 wStr(157, 100, ''); // linkname
20574 wStr(257, 6, 'ustar'); // magic
20575 wStr(263, 2, '00'); // version
20576 wStr(265, 32, ''); // uname
20577 wStr(297, 32, ''); // gname
20578 wOct(329, 8, 0); // devmajor
20579 wOct(337, 8, 0); // devminor
20580 wStr(345, 155, prefix); // prefix
20581 // Compute checksum (sum of all bytes, placeholder = 32).
20582 var chk = 0;
20583 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20584 var cs = chk.toString(8);
20585 while (cs.length < 6) cs = '0' + cs;
20586 wStr(148, 8, cs + '\0 ');
20587 return hdr;
20588 }
20589
20590 // Build tar.gz one file at a time, piping through CompressionStream.
20591 // RAM usage = compressed output buffer + one file at a time.
20592 (async function () {
20593 try {
20594 var BLOCK = 512;
20595 var cs = new CompressionStream('gzip');
20596 var writer = cs.writable.getWriter();
20597 var chunks = [];
20598 var reader = cs.readable.getReader();
20599 var collecting = (async function () {
20600 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20601 })();
20602
20603 for (var i = 0; i < uploadFiles.length; i++) {
20604 var file = uploadFiles[i];
20605 var path = file.webkitRelativePath || file.name;
20606 var buf = await file.arrayBuffer();
20607 var data = new Uint8Array(buf);
20608 // Header block
20609 await writer.write(buildUstarHeader(path, data.length));
20610 // Data padded to 512-byte boundary
20611 if (data.length > 0) {
20612 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20613 var block = new Uint8Array(padded);
20614 block.set(data);
20615 await writer.write(block);
20616 }
20617 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20618 if (previewPanel && targetInput === pathInput)
20619 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20620 }
20621 }
20622 // End-of-archive: two 512-byte zero blocks
20623 await writer.write(new Uint8Array(BLOCK * 2));
20624 await writer.close();
20625 await collecting;
20626
20627 var blob = new Blob(chunks, { type: 'application/gzip' });
20628 var sizeMB = (blob.size / 1048576).toFixed(1);
20629 if (previewPanel && targetInput === pathInput)
20630 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20631
20632 var resp = await fetch('/api/upload-tarball', {
20633 method: 'POST',
20634 headers: { 'Content-Type': 'application/gzip' },
20635 body: blob
20636 });
20637 var d = await resp.json();
20638 if (d && d.tmp_path) {
20639 applyUploadResult(d.tmp_path, {
20640 compressed_bytes: d.compressed_bytes || 0,
20641 original_bytes: d.original_bytes || 0
20642 });
20643 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20644 } catch (e) {
20645 showBannerToast('Upload failed: ' + String(e), true);
20646 if (browseBtn) browseBtn.disabled = false;
20647 inputEl.value = '';
20648 }
20649 })();
20650
20651 } else {
20652 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20653 // Used only on browsers that lack CompressionStream (pre-2023).
20654 var BATCH = 200;
20655 var batches = [];
20656 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20657 var totalBatches = batches.length;
20658 if (previewPanel && targetInput === pathInput)
20659 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20660
20661 function sendBatch(idx, currentUploadId, lastTmpPath) {
20662 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20663 if (previewPanel && targetInput === pathInput && totalBatches > 1)
20664 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20665 Promise.all(batches[idx].map(function (file) {
20666 return fileToBase64(file).then(function (b64) {
20667 return { path: file.webkitRelativePath || file.name, content: b64 };
20668 });
20669 })).then(function (fileList) {
20670 var body = { files: fileList };
20671 if (currentUploadId) body.upload_id = currentUploadId;
20672 return fetch('/api/upload-directory', {
20673 method: 'POST', headers: { 'Content-Type': 'application/json' },
20674 body: JSON.stringify(body)
20675 }).then(function (r) { return r.json(); });
20676 }).then(function (d) {
20677 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20678 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20679 }).catch(function (e) {
20680 showBannerToast('Upload failed: ' + String(e), true);
20681 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20682 });
20683 }
20684 sendBatch(0, null, '');
20685 }
20686 }
20687 };
20688 inputEl.click();
20689 return;
20690 }
20691
20692 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20693 if (browseButton) browseButton.disabled = true;
20694
20695 if (previewPanel && targetInput === pathInput) {
20696 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20697 }
20698
20699 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
20700 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20701 .then(function (data) {
20702 if (data && data.selected_path) {
20703 targetInput.value = data.selected_path;
20704 scrollInputToEnd(targetInput);
20705
20706 if (targetInput === pathInput) {
20707 updateReportTitleFromPath();
20708 autoSetOutputDir(data.selected_path);
20709 fetchProjectHistory(data.selected_path);
20710 loadPreview();
20711 suggestCoverageFile(data.selected_path);
20712 }
20713
20714 updateReview();
20715 } else if (targetInput === pathInput) {
20716 loadPreview();
20717 }
20718 })
20719 .catch(function () {
20720 window.alert("Directory picker request failed.");
20721 if (previewPanel && targetInput === pathInput) {
20722 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20723 }
20724 })
20725 .finally(function () {
20726 if (browseButton) browseButton.disabled = false;
20727 });
20728 }
20729
20730 if (themeToggle) {
20731 themeToggle.addEventListener("click", function () {
20732 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20733 applyTheme(nextTheme);
20734 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20735 });
20736 }
20737
20738 stepButtons.forEach(function (button) {
20739 button.addEventListener("click", function () {
20740 var target = Number(button.getAttribute("data-step-target"));
20741 // Block jumping forward off step 1 while the preview / upload is running
20742 // or while a multi-repository selection is unacknowledged.
20743 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20744 setStep(target);
20745 });
20746 });
20747
20748 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20749 button.addEventListener("click", function () {
20750 var target = Number(button.getAttribute("data-step-target")) || 1;
20751 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20752 setStep(target);
20753 });
20754 });
20755
20756 // True when the project path is untouched from the bundled sample default.
20757 function isDefaultSamplePath() {
20758 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
20759 }
20760
20761 var defaultPathOverlay = document.getElementById("default-path-overlay");
20762 function closeDefaultPathModal() {
20763 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20764 }
20765 function openDefaultPathModal() {
20766 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20767 }
20768
20769 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20770 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20771 // that borrow the .next-step style class but carry no data-next target).
20772 if (!button.hasAttribute("data-next")) return;
20773 button.addEventListener("click", function () {
20774 // Guard step 1 → 2: block while the scope preview / upload is still running
20775 // or while a multi-repository selection is unacknowledged.
20776 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
20777 // Guard step 1 → 2: warn when the project path is still the sample default.
20778 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20779 openDefaultPathModal();
20780 return;
20781 }
20782 updateReview();
20783 setStep(Number(button.getAttribute("data-next")));
20784 });
20785 });
20786
20787 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20788 if (!button.hasAttribute("data-prev")) return;
20789 button.addEventListener("click", function () {
20790 setStep(Number(button.getAttribute("data-prev")));
20791 });
20792 });
20793
20794 // Default-sample-path confirmation modal wiring.
20795 var defaultPathProceed = document.getElementById("default-path-proceed");
20796 if (defaultPathProceed) {
20797 defaultPathProceed.addEventListener("click", function () {
20798 closeDefaultPathModal();
20799 updateReview();
20800 setStep(2);
20801 });
20802 }
20803 var defaultPathCancel = document.getElementById("default-path-cancel");
20804 if (defaultPathCancel) {
20805 defaultPathCancel.addEventListener("click", function () {
20806 closeDefaultPathModal();
20807 if (pathInput) { pathInput.focus(); pathInput.select(); }
20808 });
20809 }
20810 if (defaultPathOverlay) {
20811 defaultPathOverlay.addEventListener("click", function (e) {
20812 if (e.target === defaultPathOverlay) closeDefaultPathModal();
20813 });
20814 }
20815 document.addEventListener("keydown", function (e) {
20816 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20817 closeDefaultPathModal();
20818 }
20819 });
20820
20821 document.addEventListener("keydown", function (e) {
20822 var tag = (document.activeElement || {}).tagName || "";
20823 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20824 if (e.altKey || e.ctrlKey || e.metaKey) return;
20825 if (e.key === "ArrowRight" && currentStep < 4) {
20826 if (currentStep === 1 && step1ForwardBlocked()) return;
20827 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20828 updateReview(); setStep(currentStep + 1);
20829 }
20830 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20831 });
20832
20833 if (useSamplePath) {
20834 useSamplePath.addEventListener("click", function () {
20835 pathInput.value = "testing/fixtures/basic";
20836 updateReportTitleFromPath();
20837 autoSetOutputDir("testing/fixtures/basic");
20838 loadPreview();
20839 suggestCoverageFile("testing/fixtures/basic");
20840 });
20841 }
20842
20843 if (useDefaultOutput) {
20844 useDefaultOutput.addEventListener("click", function () {
20845 delete outputDirInput.dataset.userEdited;
20846 autoSetOutputDir(pathInput ? pathInput.value : "");
20847 updateReview();
20848 });
20849 }
20850
20851 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20852 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20853
20854 // ── Drag-and-drop directory upload (server mode only) ─────────────────
20855 // Dropping a folder onto the path field bypasses Chrome's
20856 // "Upload X files to this site?" confirmation dialog.
20857 async function readDirRecursively(dirEntry, basePath) {
20858 var reader = dirEntry.createReader();
20859 var all = [];
20860 for (;;) {
20861 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20862 if (!batch.length) break;
20863 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20864 }
20865 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20866 var out = [];
20867 for (var i = 0; i < all.length; i++) {
20868 var sub = all[i];
20869 if (sub.isFile) {
20870 var f = await new Promise(function(res) { sub.file(res); });
20871 out.push({ file: f, path: basePath + '/' + sub.name });
20872 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20873 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20874 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20875 }
20876 }
20877 return out;
20878 }
20879
20880 function setupPathDropZone() {
20881 if (!SERVER_MODE || !pathInput) return;
20882 var CODE_EXTS = new Set([
20883 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20884 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20885 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20886 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20887 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20888 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20889 ]);
20890 pathInput.addEventListener('dragover', function(e) {
20891 e.preventDefault();
20892 pathInput.classList.add('drag-over');
20893 });
20894 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20895 pathInput.addEventListener('drop', function(e) {
20896 e.preventDefault();
20897 pathInput.classList.remove('drag-over');
20898 var items = e.dataTransfer.items;
20899 if (!items || !items.length) return;
20900 var dirEntry = null;
20901 for (var i = 0; i < items.length; i++) {
20902 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20903 if (entry && entry.isDirectory) { dirEntry = entry; break; }
20904 }
20905 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20906 var btn = browsePath;
20907 if (btn) btn.disabled = true;
20908 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20909
20910 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20911 var total = allEntries.length;
20912 var codeEntries = allEntries.filter(function(e) {
20913 var n = e.file.name;
20914 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20915 var dot = n.lastIndexOf('.');
20916 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20917 });
20918 var kept = codeEntries.length;
20919 if (kept === 0) {
20920 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20921 if (btn) btn.disabled = false; return;
20922 }
20923
20924 function finish(tmpPath, sizes) {
20925 pathInput.value = tmpPath;
20926 scrollInputToEnd(pathInput);
20927 if (sizes) {
20928 window._lastUploadSizes = sizes;
20929 var sizeText = document.getElementById('project-size-text');
20930 var sizeBtn = document.getElementById('project-size-btn');
20931 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20932 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20933 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20934 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20935 }
20936 updateReportTitleFromPath();
20937 autoSetOutputDir(tmpPath);
20938 fetchProjectHistory(tmpPath);
20939 loadPreview();
20940 suggestCoverageFile(tmpPath);
20941 updateReview();
20942 if (btn) btn.disabled = false;
20943 }
20944
20945 if (typeof CompressionStream === 'undefined') {
20946 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20947 if (btn) btn.disabled = false; return;
20948 }
20949
20950 try {
20951 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20952 var BLOCK = 512;
20953 var cs = new CompressionStream('gzip');
20954 var wtr = cs.writable.getWriter();
20955 var chunks = [];
20956 var rdr = cs.readable.getReader();
20957 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20958
20959 function buildHdr(fp, sz) {
20960 var hdr = new Uint8Array(BLOCK);
20961 var enc = new TextEncoder();
20962 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]; }
20963 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20964 var nm = fp, pfx = '';
20965 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); } }
20966 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20967 for (var i = 148; i < 156; i++) hdr[i] = 32;
20968 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);
20969 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20970 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20971 return hdr;
20972 }
20973
20974 for (var i = 0; i < codeEntries.length; i++) {
20975 var ce = codeEntries[i];
20976 var buf = await ce.file.arrayBuffer();
20977 var data = new Uint8Array(buf);
20978 await wtr.write(buildHdr(ce.path, data.length));
20979 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20980 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20981 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20982 }
20983 await wtr.write(new Uint8Array(BLOCK * 2));
20984 await wtr.close();
20985 await collecting;
20986
20987 var blob = new Blob(chunks, { type: 'application/gzip' });
20988 var sizeMB = (blob.size / 1048576).toFixed(1);
20989 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20990 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20991 var d = await resp.json();
20992 if (d && d.tmp_path) {
20993 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20994 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20995 } catch (err) {
20996 showBannerToast('Upload failed: ' + String(err), true);
20997 if (btn) btn.disabled = false;
20998 }
20999 }).catch(function(err) {
21000 showBannerToast('Could not read folder: ' + String(err), true);
21001 if (btn) btn.disabled = false;
21002 });
21003 });
21004 }
21005 setupPathDropZone();
21006 if (browseCoverage) {
21007 browseCoverage.addEventListener("click", function () {
21008 pickDirectory(coverageInput || pathInput, "coverage");
21009 });
21010 }
21011
21012 function setCovStatus(state, opts) {
21013 if (!covScanStatus) return;
21014 opts = opts || {};
21015 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21016 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21017 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>';
21018 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>';
21019 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>';
21020 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>';
21021 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21022 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21023 if (state === "scanning") {
21024 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21025 } else if (state === "found") {
21026 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21027 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21028 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21029 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21030 } else if (state === "hint") {
21031 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21032 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21033 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>';
21034 } else if (state === "none") {
21035 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21036 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21037 }
21038 html += '</div></div>';
21039 covScanStatus.innerHTML = html;
21040 if (state === "found") {
21041 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21042 if (useBtn) useBtn.addEventListener("click", function () {
21043 if (coverageInput) coverageInput.value = "";
21044 covAutoFilled = false;
21045 setCovStatus("idle");
21046 });
21047 }
21048 }
21049
21050 function suggestCoverageFile(projectPath) {
21051 if (!coverageInput || !covScanStatus) return;
21052 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21053 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21054 clearTimeout(coverageSuggestTimer);
21055 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21056 setCovStatus("scanning");
21057 coverageSuggestTimer = setTimeout(function () {
21058 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21059 .then(function (r) { return r.json(); })
21060 .then(function (d) {
21061 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21062 if (!d) { setCovStatus("none"); return; }
21063 if (d.found) {
21064 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21065 setCovStatus("found", { found: d.found, tool: d.tool });
21066 } else if (d.tool && d.hint) {
21067 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21068 } else {
21069 setCovStatus("none");
21070 }
21071 })
21072 .catch(function () { setCovStatus("idle"); });
21073 }, 600);
21074 }
21075
21076 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21077
21078 if (coverageInput) coverageInput.addEventListener("input", function () {
21079 covAutoFilled = false;
21080 if (!this.value.trim()) setCovStatus("idle");
21081 });
21082
21083 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21084 function collapseLanguagePills() {
21085 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21086 rows.forEach(function(row) {
21087 // Remove any previous overflow chip
21088 var prev = row.querySelector('.lang-overflow-chip');
21089 if (prev) prev.remove();
21090 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21091 pills.forEach(function(p) { p.style.display = ''; });
21092 if (!pills.length) return;
21093
21094 // Measure after restoring all pills
21095 var containerRight = row.getBoundingClientRect().right;
21096 var hidden = [];
21097 for (var i = pills.length - 1; i >= 1; i--) {
21098 var rect = pills[i].getBoundingClientRect();
21099 if (rect.right > containerRight + 2) {
21100 hidden.unshift(pills[i]);
21101 pills[i].style.display = 'none';
21102 } else {
21103 break;
21104 }
21105 }
21106
21107 if (hidden.length) {
21108 var chip = document.createElement('button');
21109 chip.type = 'button';
21110 chip.className = 'language-pill lang-overflow-chip';
21111 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21112 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21113 row.appendChild(chip);
21114 }
21115 });
21116 }
21117
21118 // Run after preview loads (preview panel populates language pills)
21119 var _origLoadPreviewCb = window.__previewLoaded;
21120 document.addEventListener('previewLoaded', collapseLanguagePills);
21121 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21122 setTimeout(collapseLanguagePills, 400);
21123
21124 // ── Project history & output dir auto-set ──────────────────────────
21125 var wsOutputRoot = document.getElementById("ws-output-root");
21126 var wsScanCount = document.getElementById("ws-scan-count");
21127 var wsLastScan = document.getElementById("ws-last-scan");
21128 var historyBadge = document.getElementById("path-history-badge");
21129 var historyTimer = null;
21130
21131 var wsOutputLink = document.getElementById("ws-output-link");
21132 function syncStripOutputRoot() {
21133 var val = outputDirInput ? outputDirInput.value : "";
21134 var display = val || "project/sloc";
21135 if (wsOutputRoot) wsOutputRoot.textContent = display;
21136 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21137 }
21138
21139 function scrollInputToEnd(input) {
21140 if (!input) return;
21141 // Defer so the DOM has the new value before we measure scroll width.
21142 requestAnimationFrame(function () {
21143 input.scrollLeft = input.scrollWidth;
21144 input.selectionStart = input.selectionEnd = input.value.length;
21145 });
21146 }
21147
21148 function autoSetOutputDir(projectPath) {
21149 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21150 if (GIT_MODE && GIT_OUTPUT_DIR) {
21151 outputDirInput.value = GIT_OUTPUT_DIR;
21152 scrollInputToEnd(outputDirInput);
21153 syncStripOutputRoot();
21154 updateReview();
21155 return;
21156 }
21157 if (!projectPath || !projectPath.trim()) return;
21158 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21159 outputDirInput.value = cleaned + "/sloc";
21160 scrollInputToEnd(outputDirInput);
21161 syncStripOutputRoot();
21162 updateReview();
21163 }
21164
21165 var wsBranch = document.getElementById("ws-branch");
21166
21167 function fetchProjectHistory(projectPath) {
21168 if (!projectPath || !projectPath.trim()) {
21169 if (wsScanCount) wsScanCount.textContent = "\u2014";
21170 if (wsLastScan) wsLastScan.textContent = "\u2014";
21171 if (wsBranch) wsBranch.textContent = "\u2014";
21172 if (historyBadge) historyBadge.style.display = "none";
21173 return;
21174 }
21175 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21176 .then(function (r) { return r.ok ? r.json() : null; })
21177 .then(function (data) {
21178 if (!data) return;
21179 var countStr = data.scan_count > 0
21180 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21181 : "never";
21182 var tsStr = data.last_scan_timestamp
21183 ? data.last_scan_timestamp.replace(" UTC","")
21184 : "\u2014";
21185 if (wsScanCount) wsScanCount.textContent = countStr;
21186 if (wsLastScan) wsLastScan.textContent = tsStr;
21187 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21188 if (data.scan_count > 0) {
21189 if (historyBadge) {
21190 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21191 historyBadge.textContent = data.scan_count + " previous scan" +
21192 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21193 "Last: " + (data.last_scan_timestamp || "\u2014") +
21194 " \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.";
21195 historyBadge.className = "path-history-badge found";
21196 historyBadge.style.display = "";
21197 }
21198 } else {
21199 if (historyBadge) historyBadge.style.display = "none";
21200 }
21201 })
21202 .catch(function () {});
21203 }
21204
21205 function onPathChange() {
21206 var val = pathInput ? pathInput.value : "";
21207 // Discard stale upload sizes when the user edits the path manually.
21208 window._lastUploadSizes = null;
21209 updateReportTitleFromPath();
21210 autoSetOutputDir(val);
21211 updateSidebarSummary();
21212 clearTimeout(historyTimer);
21213 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21214 if (previewTimer) clearTimeout(previewTimer);
21215 previewTimer = setTimeout(loadPreview, 280);
21216 suggestCoverageFile(val);
21217 }
21218
21219 if (pathInput) {
21220 pathInput.addEventListener("input", onPathChange);
21221 }
21222
21223 if (outputDirInput) {
21224 outputDirInput.addEventListener("input", function () {
21225 outputDirInput.dataset.userEdited = "1";
21226 syncStripOutputRoot();
21227 updateReview();
21228 });
21229 }
21230
21231 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21232 if (!node) return;
21233 node.addEventListener("input", function () {
21234 updateReview();
21235 if (previewTimer) clearTimeout(previewTimer);
21236 previewTimer = setTimeout(loadPreview, 280);
21237 });
21238 });
21239
21240 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21241 var node = document.getElementById(id);
21242 if (node) node.addEventListener("change", updateReview);
21243 });
21244
21245 if (reportTitleInput) {
21246 reportTitleInput.addEventListener("input", function () {
21247 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21248 updateReportTitleFromPath();
21249 updateReview();
21250 });
21251 }
21252
21253 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21254 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21255 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21256 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21257
21258 if (coverageInput) {
21259 coverageInput.addEventListener("input", function () {
21260 if (coverageInput.value.trim()) setCovStatus("idle");
21261 });
21262 }
21263
21264 if (form && loading && submitButton) {
21265 form.addEventListener("submit", function (e) {
21266 e.preventDefault();
21267 submitButton.disabled = true;
21268 submitButton.textContent = "Scanning...";
21269 startAsyncAnalysis(new FormData(form));
21270 });
21271 }
21272
21273 function openPath(folder) {
21274 if (!folder) return;
21275 fetch('/open-path?path=' + encodeURIComponent(folder))
21276 .then(function (r) { return r.json(); })
21277 .then(function (d) {
21278 if (d && d.server_mode_disabled)
21279 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21280 })
21281 .catch(function () {});
21282 }
21283
21284 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21285 btn.addEventListener('click', function () {
21286 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21287 });
21288 });
21289
21290 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21291 if (wsOutputLink) {
21292 wsOutputLink.addEventListener('click', function () {
21293 openPath(wsOutputLink.dataset.folder || '');
21294 });
21295 }
21296
21297 loadSavedTheme();
21298 updateMixedPolicyUI();
21299 updatePythonDocstringUI();
21300 applyScanPreset();
21301 updatePresetDescriptions();
21302 applyArtifactPreset();
21303 updateReview();
21304 updateScrollProgress(); // initialise bar to 0% (step 1)
21305 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21306 onPathChange(); // seed output dir, history badge, and preview from initial path
21307 updateStepNav(1);
21308
21309 // Restore step from URL hash on initial load (e.g., back-forward cache)
21310 (function() {
21311 var hashMatch = location.hash.match(/^#step([1-4])$/);
21312 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21313 })();
21314
21315 (function randomizeWatermarks() {
21316 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21317 if (!wms.length) return;
21318 var placed = [];
21319 function tooClose(top, left) {
21320 for (var i = 0; i < placed.length; i++) {
21321 var dt = Math.abs(placed[i][0] - top);
21322 var dl = Math.abs(placed[i][1] - left);
21323 if (dt < 16 && dl < 12) return true;
21324 }
21325 return false;
21326 }
21327 function pick(leftBand) {
21328 for (var attempt = 0; attempt < 50; attempt++) {
21329 var top = Math.random() * 88 + 2;
21330 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21331 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21332 }
21333 var top = Math.random() * 88 + 2;
21334 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21335 placed.push([top, left]);
21336 return [top, left];
21337 }
21338 var half = Math.floor(wms.length / 2);
21339 wms.forEach(function (img, i) {
21340 var pos = pick(i < half);
21341 var size = Math.floor(Math.random() * 80 + 110);
21342 var rot = (Math.random() * 360).toFixed(1);
21343 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21344 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;
21345 });
21346 })();
21347
21348 (function spawnCodeParticles() {
21349 var container = document.getElementById('code-particles');
21350 if (!container) return;
21351 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'];
21352 for (var i = 0; i < 38; i++) {
21353 (function(idx) {
21354 var el = document.createElement('span');
21355 el.className = 'code-particle';
21356 el.textContent = snippets[idx % snippets.length];
21357 var left = Math.random() * 94 + 2;
21358 var top = Math.random() * 88 + 6;
21359 var dur = (Math.random() * 10 + 9).toFixed(1);
21360 var delay = (Math.random() * 18).toFixed(1);
21361 var rot = (Math.random() * 26 - 13).toFixed(1);
21362 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21363 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';
21364 container.appendChild(el);
21365 })(i);
21366 }
21367 })();
21368 })();
21369 </script>
21370 <script nonce="{{ csp_nonce }}">
21371 (function () {
21372 var raw = {{ prefill_json|safe }};
21373 if (!raw || typeof raw !== 'object' || !raw.path) return;
21374 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21375 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21376 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21377 setVal('path', raw.path || '');
21378 setVal('include_globs', raw.include_globs || '');
21379 setVal('exclude_globs', raw.exclude_globs || '');
21380 setVal('output_dir', raw.output_dir || '');
21381 setVal('report_title', raw.report_title || '');
21382 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21383 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21384 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21385 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21386 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21387 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21388 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21389 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21390 setChecked('generate_html', raw.generate_html !== false);
21391 setChecked('generate_pdf', !!raw.generate_pdf);
21392 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21393 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21394 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21395 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21396 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21397 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21398 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21399 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21400 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21401 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21402 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21403 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21404 // Trigger dynamic UI updates after pre-fill.
21405 setTimeout(function () {
21406 var pathEl = document.getElementById('path');
21407 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21408 var policyEl = document.getElementById('mixed_line_policy');
21409 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21410 }, 80);
21411 })();
21412 </script>
21413 <script nonce="{{ csp_nonce }}">
21414 (function(){
21415 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'}];
21416 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);});}
21417 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21418 function init(){
21419 var btn=document.getElementById('settings-btn');if(!btn)return;
21420 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21421 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>';
21422 document.body.appendChild(m);
21423 var g=document.getElementById('scheme-grid');
21424 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);});
21425 var cl=document.getElementById('settings-close');
21426 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);});})();
21427 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');});
21428 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21429 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21430 }
21431 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21432 }());
21433 </script>
21434 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21435 <div class="wb-ftip-arrow"></div>
21436 <span id="wb-ftip-text"></span>
21437 </div>
21438 <script nonce="{{ csp_nonce }}">(function(){
21439 var tip=document.getElementById('wb-ftip');
21440 var txt=document.getElementById('wb-ftip-text');
21441 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21442 if(!tip||!txt)return;
21443 function pos(el){
21444 var r=el.getBoundingClientRect();
21445 tip.style.display='block';
21446 var tw=tip.offsetWidth;
21447 var lx=r.left+r.width/2-tw/2;
21448 if(lx<8)lx=8;
21449 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21450 tip.style.left=lx+'px';
21451 tip.style.top=(r.bottom+8)+'px';
21452 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';}
21453 }
21454 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21455 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21456 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21457 });
21458 window.addEventListener('blur',function(){tip.style.display='none';});
21459 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21460 })();
21461 (function(){
21462 function fixArtifactHintSpacing(){
21463 var grid=document.querySelector('.artifact-grid');
21464 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21465 }
21466 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21467 }());
21468 (function(){
21469 var dot=document.getElementById('status-dot');
21470 var pingEl=document.getElementById('server-ping-ms');
21471 var tipEl=document.getElementById('server-tip-ping');
21472 var fm=document.getElementById('footer-mode');
21473 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)';}}
21474 function doPing(){
21475 var t0=performance.now();
21476 fetch('/healthz',{cache:'no-store'})
21477 .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);})
21478 .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)';}});
21479 }
21480 doPing();
21481 setInterval(doPing,5000);
21482 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');}
21483 })();
21484 </script>
21485 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21486 <footer class="site-footer">
21487 local code analysis - metrics, history and reports
21488 · <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>
21489 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21490 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21491 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21492 · <a href="/api-docs" rel="noopener">REST API</a>
21493 </footer>
21494</body>
21495</html>
21496"##,
21497 ext = "html"
21498)]
21499struct IndexTemplate {
21500 version: &'static str,
21501 prefill_json: String,
21502 csp_nonce: String,
21503 git_repo: String,
21504 git_ref: String,
21505 git_label_json: String,
21506 git_output_dir_json: String,
21507 server_mode: bool,
21508}
21509
21510#[derive(Template)]
21513#[template(
21514 source = r##"
21515<!doctype html>
21516<html lang="en">
21517<head>
21518 <meta charset="utf-8">
21519 <meta name="viewport" content="width=device-width, initial-scale=1">
21520 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21521 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21522 <script type="application/ld+json">
21523 {
21524 "@context": "https://schema.org",
21525 "@type": "SoftwareApplication",
21526 "name": "oxide-sloc",
21527 "applicationCategory": "DeveloperApplication",
21528 "operatingSystem": "Windows, Linux",
21529 "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.",
21530 "softwareVersion": "{{ version }}",
21531 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21532 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21533 "url": "https://github.com/oxide-sloc/oxide-sloc",
21534 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21535 "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",
21536 "programmingLanguage": "Rust",
21537 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21538 }
21539 </script>
21540 <style nonce="{{ csp_nonce }}">
21541 :root {
21542 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21543 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21544 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21545 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21546 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21547 }
21548 body.dark-theme {
21549 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21550 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21551 }
21552 *{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;}
21553 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21554 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21555 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21556 .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;}
21557 @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));}}
21558 .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);}
21559 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21560 .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));}
21561 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21562 .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;}
21563 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21564 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21565 @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; } }
21566 .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;}
21567 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21568 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21569 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21570 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21571 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21572 .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;}
21573 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21574 .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);}
21575 .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;}
21576 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21577 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21578 .settings-modal-body{padding:14px 16px 16px;}
21579 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21580 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21581 .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;}
21582 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21583 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21584 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21585 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21586 .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;}
21587 .tz-select:focus{border-color:var(--oxide);}
21588 .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;}
21589 .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;}
21590 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21591 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21592 .hero{text-align:center;margin:0 auto 18px;}
21593 .hero-logo-wrap{display:inline-block;cursor:default;}
21594 .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;}
21595 .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;}
21596 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21597 .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;}
21598 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%);}
21599 .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;
21600 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21601 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21602 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;}
21603 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21604 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21605 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;}
21606 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21607 .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;}
21608 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21609 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21610 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21611 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21612 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21613 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21614 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21615 .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;}
21616 .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;}
21617 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21618 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21619 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21620 .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);}
21621 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21622 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21623 .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);}
21624 .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);}
21625 .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);}
21626 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21627 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21628 .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;}
21629 body.dark-theme .action-card-cta{color:var(--oxide);}
21630 .action-card.view .action-card-cta{color:var(--accent-2);}
21631 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21632 .action-card.compare .action-card-cta{color:#7c3aed;}
21633 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21634 .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);}
21635 .action-card.git-tools .action-card-cta{color:#15803d;}
21636 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21637 .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);}
21638 .action-card.trend .action-card-cta{color:#0e7490;}
21639 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21640 .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);}
21641 .action-card.automation .action-card-cta{color:#b45309;}
21642 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21643 .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);}
21644 .action-card.test-metrics .action-card-cta{color:#be185d;}
21645 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21646 .action-card:hover .action-card-cta{gap:12px;}
21647 .action-card.card-split{flex-direction:row;align-items:stretch;}
21648 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21649 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21650 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21651 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21652 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21653 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21654 .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;}
21655 .ac-badge.active{opacity:1;}
21656 .ac-badge.github{border-color:#555;color:#555;}
21657 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21658 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21659 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21660 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21661 body.dark-theme .ac-right-row{color:var(--muted);}
21662 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21663 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21664 .divider{height:1px;background:var(--line);margin:32px 0;}
21665 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21666 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21667 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21668 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21669 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21670 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21671 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21672 body.dark-theme .info-chip-val{color:var(--oxide);}
21673 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21674 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21675 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21676 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21677 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21678 border:6px solid transparent;border-top-color:var(--text);}
21679 .info-chip:hover .info-chip-tip{display:block;}
21680 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21681 .chip-slide.fading{filter:blur(5px);opacity:0;}
21682 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21683 .site-footer a{color:var(--muted);}
21684 .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;}
21685 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21686 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21687 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21688 .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;}
21689 .lan-badge.local{background:var(--oxide-2);}
21690 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21691 .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);}
21692 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21693 .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;}
21694 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21695 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21696 .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;}
21697 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21698 .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;}
21699 .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);}
21700 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21701 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21702 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21703 .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;}
21704 @media (max-height: 1100px) {
21705 .page{padding-top:10px;}
21706 .hero{margin-bottom:10px;}
21707 .hero-logo{width:54px;height:60px;}
21708 .hero-logo-shadow{width:42px;}
21709 .hero-title{font-size:28px;}
21710 .hero-subtitle{font-size:13px;}
21711 .card-sections{gap:12px;margin-bottom:6px;}
21712 .card-section-grid-2,.card-section-grid-3{gap:10px;}
21713 .action-card{padding:8px 15px 8px;}
21714 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21715 .action-card-icon svg{width:18px;height:18px;}
21716 .action-card-title{font-size:13px;}
21717 .action-card-desc{font-size:11px;margin-bottom:6px;}
21718 .action-card-cta{font-size:11px;}
21719 .ac-right-row{font-size:11px;}
21720 .divider{margin:14px 0;}
21721 .info-strip{gap:7px;margin-bottom:8px;}
21722 .info-chip{padding:7px 10px;}
21723 .info-chip-val{font-size:13px;}
21724 .info-chip-label{font-size:9px;}
21725 .site-footer{padding:8px 24px;font-size:12px;}
21726 .lan-local-hint{margin-top:8px;}
21727 }
21728 @media (max-height: 850px) {
21729 .page{padding-top:6px;}
21730 .hero{margin-bottom:6px;}
21731 .hero-logo{width:42px;height:46px;}
21732 .hero-title{font-size:22px;}
21733 .hero-subtitle{font-size:12px;}
21734 .card-sections{gap:10px;}
21735 .action-card-desc{margin-bottom:4px;}
21736 .divider{margin:8px 0;}
21737 .info-strip{margin-bottom:6px;}
21738 .lan-local-hint{margin-top:10px;}
21739 }
21740 </style>
21741</head>
21742<body>
21743 <div class="background-watermarks" aria-hidden="true">
21744 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21745 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21746 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21747 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21748 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21749 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21750 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21751 </div>
21752 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21753 <div class="top-nav">
21754 <div class="top-nav-inner">
21755 <a class="brand" href="/">
21756 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21757 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21758 </a>
21759 <div class="nav-right">
21760 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21761 <div class="nav-dropdown">
21762 <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>
21763 <div class="nav-dropdown-menu">
21764 <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>
21765 </div>
21766 </div>
21767 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21768 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21769 <div class="nav-dropdown">
21770 <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>
21771 <div class="nav-dropdown-menu">
21772 <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>
21773 </div>
21774 </div>
21775 <div class="server-status-wrap" id="server-status-wrap">
21776 <div class="nav-pill server-online-pill" id="server-status-pill">
21777 <span class="status-dot" id="status-dot"></span>
21778 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21779 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21780 </div>
21781 <div class="server-status-tip">
21782 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21783 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21784 </div>
21785 </div>
21786 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21787 <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>
21788 </button>
21789 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21790 <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>
21791 <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>
21792 </button>
21793 </div>
21794 </div>
21795 </div>
21796
21797 <div class="page">
21798 <div class="hero">
21799 <div class="hero-logo-wrap" id="hero-logo-wrap">
21800 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21801 </div>
21802 <div class="hero-logo-shadow"></div>
21803 <div class="hero-title-wrap">
21804 <div class="hero-title-aura" aria-hidden="true"></div>
21805 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21806 </div>
21807 <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>
21808 </div>
21809
21810 <div class="card-sections">
21811
21812 <div>
21813 <div class="card-section-label">Analysis</div>
21814 <div class="card-section-grid-2">
21815 <a class="action-card scan card-split" href="/scan-setup">
21816 <div class="action-card-left">
21817 <div class="action-card-icon">
21818 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21819 </div>
21820 <div class="action-card-title">Scan Project</div>
21821 <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>
21822 <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>
21823 </div>
21824 <div class="action-card-sep"></div>
21825 <div class="action-card-right">
21826 <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>
21827 <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>
21828 <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>
21829 <div class="ac-right-stat" id="acp-scan-stat"></div>
21830 </div>
21831 </a>
21832 <a class="action-card test-metrics card-split" href="/test-metrics">
21833 <div class="action-card-left">
21834 <div class="action-card-icon">
21835 <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>
21836 </div>
21837 <div class="action-card-title">Test Metrics</div>
21838 <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>
21839 <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>
21840 </div>
21841 <div class="action-card-sep"></div>
21842 <div class="action-card-right">
21843 <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>
21844 <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>
21845 <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>
21846 <div class="ac-right-stat" id="acp-test-stat"></div>
21847 </div>
21848 </a>
21849 </div>
21850 </div>
21851
21852 <div>
21853 <div class="card-section-label">Reports & Insights</div>
21854 <div class="card-section-grid-3">
21855 <a class="action-card view" href="/view-reports">
21856 <div class="action-card-icon">
21857 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21858 </div>
21859 <div class="action-card-title">View Reports</div>
21860 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21861 <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>
21862 </a>
21863 <a class="action-card compare" href="/compare-scans">
21864 <div class="action-card-icon">
21865 <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>
21866 </div>
21867 <div class="action-card-title">Compare Scans</div>
21868 <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>
21869 <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>
21870 </a>
21871 <a class="action-card trend" href="/trend-reports">
21872 <div class="action-card-icon">
21873 <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>
21874 </div>
21875 <div class="action-card-title">Trend Report</div>
21876 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21877 <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>
21878 </a>
21879 </div>
21880 </div>
21881
21882 <div>
21883 <div class="card-section-label">Developer Tools</div>
21884 <div class="card-section-grid-2">
21885 <a class="action-card git-tools card-split" href="/git-browser">
21886 <div class="action-card-left">
21887 <div class="action-card-icon">
21888 <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>
21889 </div>
21890 <div class="action-card-title">Git Browser</div>
21891 <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>
21892 <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>
21893 </div>
21894 <div class="action-card-sep"></div>
21895 <div class="action-card-right">
21896 <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>
21897 <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>
21898 <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>
21899 </div>
21900 </a>
21901 <a class="action-card automation card-split" href="/integrations">
21902 <div class="action-card-left">
21903 <div class="action-card-icon">
21904 <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>
21905 </div>
21906 <div class="action-card-title">Integrations</div>
21907 <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>
21908 <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>
21909 </div>
21910 <div class="action-card-sep"></div>
21911 <div class="action-card-right">
21912 <div class="ac-badges-grid">
21913 <span class="ac-badge github" id="acp-gh">GitHub</span>
21914 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
21915 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
21916 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21917 </div>
21918 <div class="ac-right-stat" id="acp-int-stat"></div>
21919 </div>
21920 </a>
21921 </div>
21922 </div>
21923
21924 </div>
21925
21926 {% if server_mode %}
21927 <div class="lan-card server">
21928 <div class="lan-card-header">
21929 <span class="lan-badge">LAN server</span>
21930 Accessible on your network
21931 </div>
21932 {% if let Some(ip) = lan_ip %}
21933 <div class="lan-url-row">
21934 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21935 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21936 <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>
21937 Copy URL
21938 </button>
21939 </div>
21940 <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>
21941 {% if has_api_key %}
21942 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
21943 {% endif %}
21944 {% else %}
21945 <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>
21946 {% endif %}
21947 </div>
21948 {% endif %}
21949
21950 <div class="divider"></div>
21951
21952 <div class="info-strip">
21953 <div class="info-chip">
21954 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21955 <div class="chip-slide">
21956 <div class="info-chip-val">60</div>
21957 <div class="info-chip-label">Languages</div>
21958 </div>
21959 </div>
21960 <div class="info-chip">
21961 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21962 <div class="chip-slide">
21963 <div class="info-chip-val">100%</div>
21964 <div class="info-chip-label">Self-contained</div>
21965 </div>
21966 </div>
21967 <div class="info-chip">
21968 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21969 <div class="chip-slide">
21970 <div class="info-chip-val">HTML+PDF</div>
21971 <div class="info-chip-label">Exportable reports</div>
21972 </div>
21973 </div>
21974 <div class="info-chip">
21975 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21976 <div class="chip-slide">
21977 <div class="info-chip-val">Webhook</div>
21978 <div class="info-chip-label">3 platforms</div>
21979 </div>
21980 </div>
21981 <div class="info-chip">
21982 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21983 <div class="chip-slide">
21984 <div class="info-chip-val">IEEE</div>
21985 <div class="info-chip-label">1045-1992</div>
21986 </div>
21987 </div>
21988 </div>
21989
21990 {% if lan_ip.is_none() %}
21991 <div class="lan-local-hint">
21992 <strong>Want teammates on the same network to access this?</strong><br>
21993 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
21994 </div>
21995 {% endif %}
21996 </div>
21997
21998 <footer class="site-footer">
21999 local code analysis - metrics, history and reports
22000 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22001 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22002 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22003 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22004 · <a href="/api-docs" rel="noopener">REST API</a>
22005 </footer>
22006
22007 <script nonce="{{ csp_nonce }}">
22008 (function () {
22009 var storageKey = 'oxide-sloc-theme';
22010 var body = document.body;
22011 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22012 var toggle = document.getElementById('theme-toggle');
22013 if (toggle) toggle.addEventListener('click', function () {
22014 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22015 body.classList.toggle('dark-theme', next === 'dark');
22016 try { localStorage.setItem(storageKey, next); } catch(e) {}
22017 });
22018 var copyBtn = document.getElementById('lan-copy-btn');
22019 if (copyBtn) copyBtn.addEventListener('click', function() {
22020 var btn = this;
22021 var el = document.getElementById('lan-url-val');
22022 if (!el) return;
22023 var url = el.textContent.trim();
22024 if (navigator.clipboard) {
22025 navigator.clipboard.writeText(url).then(function() {
22026 var orig = btn.innerHTML;
22027 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!';
22028 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22029 });
22030 }
22031 });
22032 (function randomizeWatermarks() {
22033 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22034 if (!wms.length) return;
22035 var placed = [];
22036 function tooClose(top, left) {
22037 for (var i = 0; i < placed.length; i++) {
22038 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22039 if (dt < 16 && dl < 12) return true;
22040 }
22041 return false;
22042 }
22043 function pick(leftBand) {
22044 for (var attempt = 0; attempt < 50; attempt++) {
22045 var top = Math.random() * 88 + 2;
22046 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22047 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22048 }
22049 var top = Math.random() * 88 + 2;
22050 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22051 placed.push([top, left]); return [top, left];
22052 }
22053 var half = Math.floor(wms.length / 2);
22054 wms.forEach(function (img, i) {
22055 var pos = pick(i < half);
22056 var size = Math.floor(Math.random() * 100 + 120);
22057 var rot = (Math.random() * 360).toFixed(1);
22058 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22059 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;
22060 });
22061 })();
22062
22063 (function spawnCodeParticles() {
22064 var container = document.getElementById('code-particles');
22065 if (!container) return;
22066 var snippets = [
22067 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22068 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22069 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22070 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22071 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22072 ];
22073 var count = 38;
22074 for (var i = 0; i < count; i++) {
22075 (function(idx) {
22076 var el = document.createElement('span');
22077 el.className = 'code-particle';
22078 var text = snippets[idx % snippets.length];
22079 el.textContent = text;
22080 var left = Math.random() * 94 + 2;
22081 var top = Math.random() * 88 + 6;
22082 var dur = (Math.random() * 10 + 9).toFixed(1);
22083 var delay = (Math.random() * 18).toFixed(1);
22084 var rot = (Math.random() * 26 - 13).toFixed(1);
22085 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22086 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22087 + '--rot:' + rot + 'deg;--op:' + op + ';'
22088 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22089 container.appendChild(el);
22090 })(i);
22091 }
22092 })();
22093 (function heroAnimations() {
22094 var sub = document.getElementById('hero-subtitle');
22095 if (sub) {
22096 var full = sub.textContent.trim();
22097 sub.textContent = '';
22098 sub.style.opacity = '1';
22099 var cursor = document.createElement('span');
22100 cursor.className = 'hero-cursor';
22101 sub.appendChild(cursor);
22102 var i = 0;
22103 setTimeout(function() {
22104 var iv = setInterval(function() {
22105 if (i < full.length) {
22106 sub.insertBefore(document.createTextNode(full[i]), cursor);
22107 i++;
22108 } else {
22109 clearInterval(iv);
22110 setTimeout(function() {
22111 cursor.style.transition = 'opacity 1s ease';
22112 cursor.style.opacity = '0';
22113 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22114 }, 2400);
22115 }
22116 }, 11);
22117 }, 374);
22118 }
22119 })();
22120 (function logoBob() {
22121 var logo = document.querySelector('.hero-logo');
22122 var shadow = document.querySelector('.hero-logo-shadow');
22123 if (!logo) return;
22124 var cycleStart = null, cycleDur = 3600;
22125 var peakY = -14, peakScale = 1.07, peakRot = 0;
22126 function newCycle() {
22127 cycleDur = 3000 + Math.random() * 1840;
22128 peakY = -(9 + Math.random() * 13.8);
22129 peakScale = 1.04 + Math.random() * 0.081;
22130 peakRot = (Math.random() * 11.5 - 5.75);
22131 }
22132 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22133 newCycle();
22134 function frame(ts) {
22135 if (cycleStart === null) cycleStart = ts;
22136 var t = (ts - cycleStart) / cycleDur;
22137 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22138 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22139 var y = peakY * phase;
22140 var sc = 1 + (peakScale - 1) * phase;
22141 var rot = peakRot * Math.sin(Math.PI * phase);
22142 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22143 if (shadow) {
22144 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22145 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22146 }
22147 requestAnimationFrame(frame);
22148 }
22149 requestAnimationFrame(frame);
22150 })();
22151 (function mouseEffects() {
22152 var heroTitle = document.getElementById('hero-title');
22153 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22154 function tick() {
22155 raf = null;
22156 if (heroTitle) {
22157 var r = heroTitle.getBoundingClientRect();
22158 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22159 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22160 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22161 }
22162 }
22163 document.addEventListener('mousemove', function(e) {
22164 mx = e.clientX; my = e.clientY;
22165 if (!raf) raf = requestAnimationFrame(tick);
22166 });
22167 document.addEventListener('mouseleave', function() {
22168 if (heroTitle) {
22169 heroTitle.style.transition = 'transform 0.5s ease';
22170 heroTitle.style.transform = '';
22171 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22172 }
22173 });
22174 document.querySelectorAll('.action-card').forEach(function(card) {
22175 card.addEventListener('mousemove', function(e) {
22176 var rect = card.getBoundingClientRect();
22177 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22178 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22179 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22180 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22181 });
22182 card.addEventListener('mouseleave', function() {
22183 card.style.transition = '';
22184 card.style.transform = '';
22185 });
22186 });
22187 })();
22188 (function chipSlideshow() {
22189 var slides = [
22190 [{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'}],
22191 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22192 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22193 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22194 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22195 ];
22196 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22197 var indices = [0,0,0,0,0];
22198 var paused = [false,false,false,false,false];
22199 chips.forEach(function(chip, i) {
22200 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22201 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22202 });
22203 function advance(i) {
22204 if (paused[i]) return;
22205 var chip = chips[i];
22206 var inner = chip.querySelector('.chip-slide');
22207 if (!inner) return;
22208 inner.classList.add('fading');
22209 setTimeout(function() {
22210 indices[i] = (indices[i] + 1) % slides[i].length;
22211 var s = slides[i][indices[i]];
22212 chip.querySelector('.info-chip-val').textContent = s.v;
22213 chip.querySelector('.info-chip-label').textContent = s.l;
22214 inner.classList.remove('fading');
22215 }, 720);
22216 }
22217 setInterval(function() {
22218 chips.forEach(function(chip, i) { advance(i); });
22219 }, 6000);
22220 })();
22221 (function cardLiveData() {
22222 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22223 var el = document.getElementById('acp-scan-stat');
22224 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22225 }).catch(function(){});
22226 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22227 var el = document.getElementById('acp-test-stat');
22228 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22229 }).catch(function(){});
22230 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22231 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22232 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22233 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22234 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22235 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22236 var stat = document.getElementById('acp-int-stat');
22237 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22238 }).catch(function(){});
22239 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22240 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22241 }).catch(function(){});
22242 })();
22243 })();
22244 </script>
22245 <script nonce="{{ csp_nonce }}">
22246 (function(){
22247 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'}];
22248 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);});}
22249 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22250 function init(){
22251 var btn=document.getElementById('settings-btn');if(!btn)return;
22252 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22253 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>';
22254 document.body.appendChild(m);
22255 var g=document.getElementById('scheme-grid');
22256 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);});
22257 var cl=document.getElementById('settings-close');
22258 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);});})();
22259 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');});
22260 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22261 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22262 }
22263 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22264 }());
22265 </script>
22266 <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>
22267</body>
22268</html>
22269"##,
22270 ext = "html"
22271)]
22272struct SplashTemplate {
22273 csp_nonce: String,
22274 server_mode: bool,
22275 lan_ip: Option<String>,
22276 port: u16,
22277 version: &'static str,
22278 has_api_key: bool,
22279}
22280
22281#[derive(Template)]
22284#[template(
22285 source = r##"
22286<!doctype html>
22287<html lang="en">
22288<head>
22289 <meta charset="utf-8">
22290 <meta name="viewport" content="width=device-width, initial-scale=1">
22291 <title>OxideSLOC — Start a Scan</title>
22292 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22293 <style nonce="{{ csp_nonce }}">
22294 :root {
22295 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22296 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22297 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22298 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22299 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22300 }
22301 body.dark-theme {
22302 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22303 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22304 }
22305 *{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;}
22306 .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);}
22307 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22308 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22309 .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));}
22310 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22311 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22312 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22313 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22314 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22315 @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; } }
22316 .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;}
22317 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22318 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22319 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22320 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22321 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22322 .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;}
22323 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22324 .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);}
22325 .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;}
22326 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22327 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22328 .settings-modal-body{padding:14px 16px 16px;}
22329 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22330 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22331 .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;}
22332 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22333 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22334 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22335 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22336 .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;}
22337 .tz-select:focus{border-color:var(--oxide);}
22338 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22339 .page-header{text-align:center;margin-bottom:16px;}
22340 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22341 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22342 /* Cards */
22343 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22344 .option-card-wrap{position:relative;}
22345 .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;}
22346 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22347 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22348 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22349 .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;}
22350 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22351 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22352 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22353 .card-top-row{display:flex;align-items:center;gap:20px;}
22354 /* Two-column layout inside each card */
22355 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22356 .card-left{display:flex;align-items:flex-start;min-width:0;}
22357 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22358 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22359 .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);}
22360 .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);}
22361 .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);}
22362 .card-text{min-width:0;}
22363 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22364 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22365 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22366 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22367 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22368 /* Right CTA column */
22369 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22370 .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;}
22371 /* Re-scan count badge */
22372 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22373 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22374 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22375 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22376 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22377 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22378 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22379 body.dark-theme .btn-secondary{color:var(--oxide);}
22380 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22381 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22382 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22383 .file-input-wrap{position:relative;width:100%;}
22384 .file-input-wrap .btn{width:100%;}
22385 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22386 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22387 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22388 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22389 .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;}
22390 @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));}}
22391 /* Recent list (card 3 — full-width section below header) */
22392 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22393 .recent-list{display:flex;flex-direction:column;gap:8px;}
22394 .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;}
22395 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22396 .recent-item-info{flex:1;min-width:0;}
22397 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22398 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22399 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22400 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22401 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22402 .site-footer a{color:var(--muted);}
22403 @media(max-width:680px){
22404 .card-body{grid-template-columns:1fr;}
22405 .card-right{flex-direction:row;flex-wrap:wrap;}
22406 .btn{flex:1;}
22407 }
22408 .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;}
22409 .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;}
22410 .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;}
22411 </style>
22412</head>
22413<body>
22414 <div class="background-watermarks" aria-hidden="true">
22415 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22416 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22417 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22418 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22419 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22420 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22421 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22422 </div>
22423 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22424 <div class="top-nav">
22425 <div class="top-nav-inner">
22426 <a class="brand" href="/">
22427 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22428 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22429 </a>
22430 <div class="nav-right">
22431 <a class="nav-pill" href="/">Home</a>
22432 <div class="nav-dropdown">
22433 <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>
22434 <div class="nav-dropdown-menu">
22435 <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>
22436 </div>
22437 </div>
22438 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22439 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22440 <div class="nav-dropdown">
22441 <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>
22442 <div class="nav-dropdown-menu">
22443 <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>
22444 </div>
22445 </div>
22446 <div class="server-status-wrap" id="server-status-wrap">
22447 <div class="nav-pill server-online-pill" id="server-status-pill">
22448 <span class="status-dot" id="status-dot"></span>
22449 <span id="server-status-label">Server</span>
22450 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22451 </div>
22452 <div class="server-status-tip">
22453 OxideSLOC is running — accessible on your network.
22454 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22455 </div>
22456 </div>
22457 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22458 <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>
22459 </button>
22460 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22461 <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>
22462 <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>
22463 </button>
22464 </div>
22465 </div>
22466 </div>
22467
22468 <div class="page">
22469 <div class="page-header">
22470 <h1>How would you like to scan?</h1>
22471 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22472 </div>
22473
22474 <div class="option-grid">
22475
22476 <!-- Option 1: New scan -->
22477 <div class="option-card-wrap">
22478 <div class="option-card">
22479 <div class="option-icon new-scan">
22480 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22481 </div>
22482 <div class="card-body">
22483 <div class="card-left">
22484 <div class="card-text">
22485 <div class="option-title">Start a new scan</div>
22486 <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>
22487 <ul class="feature-list">
22488 <li>Live project scope preview before you run</li>
22489 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22490 <li>HTML, PDF, and JSON output — your choice</li>
22491 </ul>
22492 </div>
22493 </div>
22494 <div class="card-right">
22495 <a class="btn btn-primary" href="/scan">
22496 Configure & scan
22497 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22498 </a>
22499 <p class="card-tip">Full 4-step setup · all options</p>
22500 </div>
22501 </div>
22502 </div>
22503 </div>
22504
22505 <!-- Option 2: Load from config file -->
22506 <div class="option-card-wrap">
22507 <div class="option-card">
22508 <div class="option-icon load-config">
22509 <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>
22510 </div>
22511 <div class="card-body">
22512 <div class="card-left">
22513 <div class="card-text">
22514 <div class="option-title">Load a saved config</div>
22515 <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>
22516 <ul class="feature-list">
22517 <li>All 15 settings restored from the file</li>
22518 <li>Fully editable — change path or output dir</li>
22519 <li>Works with any scan-config.json</li>
22520 </ul>
22521 </div>
22522 </div>
22523 <div class="card-right">
22524 <div class="file-input-wrap">
22525 <button class="btn btn-secondary" id="load-config-btn" type="button">
22526 <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>
22527 Choose config file
22528 </button>
22529 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22530 </div>
22531 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22532 </div>
22533 </div>
22534 </div>
22535 </div>
22536
22537 <!-- Option 3: Re-scan recent project -->
22538 <div class="option-card-wrap">
22539 <div class="option-card" id="recent-card">
22540 <div class="card-top-row">
22541 <div class="option-icon rescan">
22542 <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>
22543 </div>
22544 <div class="card-body">
22545 <div class="card-left">
22546 <div class="card-text">
22547 <div class="option-title">Re-scan a recent project</div>
22548 <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>
22549 <ul class="feature-list">
22550 <li>All 15+ settings restored from the saved config</li>
22551 <li>Path and output dir are editable before running</li>
22552 <li>Only scans with a saved config appear here</li>
22553 </ul>
22554 </div>
22555 </div>
22556 <div class="card-right">
22557 <div class="rescan-count-box">
22558 <div class="rescan-count-num" id="rescan-count-num">—</div>
22559 <div class="rescan-count-label">saved configs</div>
22560 </div>
22561 <a class="btn btn-secondary" href="/view-reports">
22562 <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>
22563 View all runs
22564 </a>
22565 <p class="card-tip">Opens run history</p>
22566 </div>
22567 </div>
22568 </div>
22569 <div class="section-divider"></div>
22570 <div class="recent-list" id="recent-list">
22571 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22572 </div>
22573 </div>
22574 </div>
22575
22576 </div>
22577 </div>
22578
22579 <footer class="site-footer">
22580 local code analysis - metrics, history and reports
22581 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22582 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22583 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22584 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22585 · <a href="/api-docs" rel="noopener">REST API</a>
22586 </footer>
22587
22588 <script nonce="{{ csp_nonce }}">
22589 (function () {
22590 var storageKey = 'oxide-sloc-theme';
22591 var body = document.body;
22592 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22593 var toggle = document.getElementById('theme-toggle');
22594 if (toggle) toggle.addEventListener('click', function () {
22595 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22596 body.classList.toggle('dark-theme', next === 'dark');
22597 try { localStorage.setItem(storageKey, next); } catch(e) {}
22598 });
22599
22600 (function randomizeWatermarks() {
22601 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22602 if (!wms.length) return;
22603 var placed = [];
22604 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; }
22605 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]; }
22606 var half = Math.floor(wms.length / 2);
22607 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; });
22608 })();
22609 (function spawnCodeParticles() {
22610 var container = document.getElementById('code-particles');
22611 if (!container) return;
22612 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'];
22613 var count = 38;
22614 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); }
22615 })();
22616 // Recent scans data injected from server
22617 var recentScans = {{ recent_scans_json|safe }};
22618
22619 function configToParams(cfg) {
22620 var p = new URLSearchParams();
22621 p.set('prefilled', '1');
22622 if (cfg.path) p.set('path', cfg.path);
22623 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22624 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22625 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22626 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22627 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22628 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22629 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22630 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22631 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22632 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22633 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22634 if (cfg.report_title) p.set('report_title', cfg.report_title);
22635 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22636 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22637 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22638 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22639 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22640 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22641 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22642 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22643 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22644 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22645 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22646 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22647 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22648 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22649 return p;
22650 }
22651
22652 // Build recent scan list (capped at 3 visible entries)
22653 var list = document.getElementById('recent-list');
22654 var noNote = document.getElementById('no-recent-note');
22655 var hasAny = false;
22656 var MAX_RECENT = 3;
22657 if (Array.isArray(recentScans)) {
22658 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22659 var shown = 0;
22660 validEntries.forEach(function (entry) {
22661 if (shown >= MAX_RECENT) return;
22662 shown++;
22663 hasAny = true;
22664 var item = document.createElement('div');
22665 item.className = 'recent-item';
22666 item.title = 'Restore all settings and open wizard';
22667 item.innerHTML =
22668 '<div class="recent-item-info">' +
22669 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22670 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
22671 '</div>' +
22672 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22673 item.addEventListener('click', function () {
22674 var params = configToParams(entry.config);
22675 window.location.href = '/scan?' + params.toString();
22676 });
22677 list.appendChild(item);
22678 });
22679 if (validEntries.length > MAX_RECENT) {
22680 var moreEl = document.createElement('div');
22681 moreEl.className = 'recent-more-link';
22682 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
22683 list.appendChild(moreEl);
22684 }
22685 }
22686 if (hasAny && noNote) noNote.style.display = 'none';
22687 // Update count badge
22688 var countEl = document.getElementById('rescan-count-num');
22689 if (countEl) {
22690 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22691 countEl.textContent = total > 0 ? total : '0';
22692 }
22693
22694 // Config file loader
22695 var fileInput = document.getElementById('config-file-input');
22696 var fileName = document.getElementById('config-file-name');
22697 var loadBtn = document.getElementById('load-config-btn');
22698 // Wire the visible button to open the hidden file picker.
22699 if (loadBtn && fileInput) {
22700 loadBtn.addEventListener('click', function () { fileInput.click(); });
22701 }
22702 if (fileInput) {
22703 fileInput.addEventListener('change', function () {
22704 var file = fileInput.files && fileInput.files[0];
22705 if (!file) return;
22706 if (fileName) fileName.textContent = '\u2713 ' + file.name;
22707 var reader = new FileReader();
22708 reader.onload = function (e) {
22709 try {
22710 var cfg = JSON.parse(e.target.result);
22711 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22712 var params = configToParams(cfg);
22713 window.location.href = '/scan?' + params.toString();
22714 } catch (err) {
22715 alert('Could not parse config file: ' + err.message);
22716 }
22717 };
22718 reader.readAsText(file);
22719 });
22720 }
22721
22722 function escHtml(s) {
22723 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
22724 }
22725 })();
22726 </script>
22727 <script nonce="{{ csp_nonce }}">
22728 (function(){
22729 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'}];
22730 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);});}
22731 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22732 function init(){
22733 var btn=document.getElementById('settings-btn');if(!btn)return;
22734 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22735 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>';
22736 document.body.appendChild(m);
22737 var g=document.getElementById('scheme-grid');
22738 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);});
22739 var cl=document.getElementById('settings-close');
22740 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);});})();
22741 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');});
22742 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22743 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22744 }
22745 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22746 }());
22747 </script>
22748 <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]';
22749 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;}
22750 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>
22751</body>
22752</html>
22753"##,
22754 ext = "html"
22755)]
22756struct ScanSetupTemplate {
22757 version: &'static str,
22758 recent_scans_json: String,
22759 csp_nonce: String,
22760}
22761
22762#[derive(Template)]
22763#[template(
22764 source = r##"
22765<!doctype html>
22766<html lang="en">
22767<head>
22768 <meta charset="utf-8">
22769 <meta name="viewport" content="width=device-width, initial-scale=1">
22770 <title>OxideSLOC | {{ report_title }} | Report</title>
22771 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22772 <style nonce="{{ csp_nonce }}">
22773 :root {
22774 --radius: 18px;
22775 --bg: #f5efe8;
22776 --surface: rgba(255,255,255,0.82);
22777 --surface-2: #fbf7f2;
22778 --surface-3: #efe6dc;
22779 --line: #e6d0bf;
22780 --line-strong: #dcb89f;
22781 --text: #43342d;
22782 --muted: #7b675b;
22783 --muted-2: #a08777;
22784 --nav: #b85d33;
22785 --nav-2: #7a371b;
22786 --accent: #6f9bff;
22787 --accent-2: #4a78ee;
22788 --oxide: #d37a4c;
22789 --oxide-2: #b35428;
22790 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22791 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22792 --success-bg: #e8f5ed;
22793 --success-text: #1a8f47;
22794 --info-bg: #eef3ff;
22795 --info-text: #4467d8;
22796 }
22797
22798 body.dark-theme {
22799 --bg: #1b1511;
22800 --surface: #261c17;
22801 --surface-2: #2d221d;
22802 --surface-3: #372922;
22803 --line: #524238;
22804 --line-strong: #6c5649;
22805 --text: #f5ece6;
22806 --muted: #c7b7aa;
22807 --muted-2: #aa9485;
22808 --nav: #b85d33;
22809 --nav-2: #7a371b;
22810 --accent: #6f9bff;
22811 --accent-2: #4a78ee;
22812 --oxide: #d37a4c;
22813 --oxide-2: #b35428;
22814 --shadow: 0 18px 42px rgba(0,0,0,0.28);
22815 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22816 --success-bg: #163927;
22817 --success-text: #8fe2a8;
22818 --info-bg: #1c2847;
22819 --info-text: #a9c1ff;
22820 }
22821
22822 * { box-sizing: border-box; }
22823 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); }
22824 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22825 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22826 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22827 .top-nav, .page { position: relative; z-index: 2; }
22828 .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); }
22829 .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; }
22830 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22831 .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)); }
22832 .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; }
22833 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22834 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22835 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22836 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22837 .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; }
22838 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22839 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22840 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22841 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22842 @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; } }
22843 .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; }
22844 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22845 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22846 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22847 .theme-toggle .icon-sun { display:none; }
22848 body.dark-theme .theme-toggle .icon-sun { display:block; }
22849 body.dark-theme .theme-toggle .icon-moon { display:none; }
22850 .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;}
22851 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22852 .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);}
22853 .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;}
22854 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22855 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22856 .settings-modal-body{padding:14px 16px 16px;}
22857 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22858 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22859 .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;}
22860 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22861 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22862 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22863 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22864 .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;}
22865 .tz-select:focus{border-color:var(--oxide);}
22866 .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; }
22867 .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;}
22868 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22869 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22870 .hero, .panel { padding: 22px; }
22871 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22872 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22873 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22874 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22875 .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; }
22876 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22877 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22878 .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; }
22879 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22880 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22881 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22882 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22883 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22884 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22885 .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); }
22886 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22887 .delta-card-val { font-size:16px; font-weight:800; }
22888 .delta-card-val.pos { color:#1e7e34; }
22889 .delta-card-val.neg { color:var(--neg); }
22890 .delta-card-val.mod { color:#b35428; }
22891 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22892 .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; }
22893 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22894 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22895 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22896 .compare-ts { font-size:13px; color:var(--muted); }
22897 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22898 .compare-arrow { color: var(--muted); }
22899 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22900 .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; }
22901 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22902 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22903 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22904 .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; }
22905 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22906 .run-mgmt-card .action-buttons { justify-content:center; }
22907 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22908 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22909 .button, .copy-button {
22910 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;
22911 }
22912 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22913 @keyframes spin { to { transform: rotate(360deg); } }
22914 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22915 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22916 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22917 .path-item strong { display: block; margin-bottom: 6px; }
22918 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22919 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22920 .path-subitem { flex: 1; }
22921 .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); }
22922 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); }
22923 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22924 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22925 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22926 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22927 th { color: var(--muted); font-weight: 700; }
22928 tr:last-child td { border-bottom: none; }
22929 #subm-tbl col:nth-child(1){width:15%;}
22930 #subm-tbl col:nth-child(2){width:31%;}
22931 #subm-tbl col:nth-child(3){width:9%;}
22932 #subm-tbl col:nth-child(4){width:9%;}
22933 #subm-tbl col:nth-child(5){width:9%;}
22934 #subm-tbl col:nth-child(6){width:9%;}
22935 #subm-tbl col:nth-child(7){width:9%;}
22936 #subm-tbl col:nth-child(8){width:9%;}
22937 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22938 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22939 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22940 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22941 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22942 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22943 .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; }
22944 .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; }
22945 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22946 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22947 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22948 .muted { color: var(--muted); }
22949 /* Run-ID chip row (mirrors HTML report) */
22950 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22951 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22952 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22953 .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; }
22954 .run-id-chip[data-copy] { cursor:pointer; }
22955 a.run-id-chip { text-decoration:none; cursor:pointer; }
22956 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22957 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22958 .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; }
22959 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22960 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22961 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22962 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22963 a.commit-link-value { color:inherit; text-decoration:none; }
22964 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22965 .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; }
22966 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22967 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22968 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22969 .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; }
22970 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22971 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22972 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22973 /* Meta chips row */
22974 .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%; }
22975 .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; }
22976 .meta-chip:last-child { border-right:none; }
22977 .meta-chip b { color:var(--text); font-weight:700; }
22978 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22979 .site-footer a{color:var(--muted);}
22980 .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; }
22981 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22982 .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; }
22983 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22984 /* Stat chips (matches HTML report) */
22985 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22986 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22987 /* Hero stat strip: uniform grid where every card is the same width and the
22988 columns line up across both rows. JS sets the column count to ceil(n/2) so
22989 the cards always occupy exactly two rows; when the count is odd the last
22990 card spans two columns to fill the trailing cell with no empty gap. */
22991 .summary-strip-hero { align-items:stretch; }
22992 .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; }
22993 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22994 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22995 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22996 .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; }
22997 .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); }
22998 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22999 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23000 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23001 .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; }
23002 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23003 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23004 .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); }
23005 .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); }
23006 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23007 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23008 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23009 /* Submodule panel */
23010 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23011 /* Metrics tables stack */
23012 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23013 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23014 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23015 .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)); }
23016 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23017 /* Metrics table */
23018 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23019 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23020 .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; }
23021 .metrics-table thead th:not(:first-child) { text-align: right; }
23022 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23023 .metrics-table tbody tr:last-child td { border-bottom: none; }
23024 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23025 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23026 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23027 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23028 .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; }
23029 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23030 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23031 .mt-val-pos { color: var(--pos); font-weight: 700; }
23032 .mt-val-neg { color: var(--neg); font-weight: 700; }
23033 .mt-val-zero { color: var(--muted); }
23034 .mt-val-mod { color: var(--oxide-2); }
23035 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23036 @media (max-width: 1180px) {
23037 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23038 .nav-project-slot, .nav-status { justify-content:flex-start; }
23039 .hero-top { flex-direction: column; }
23040 .run-mgmt-strip { flex-direction: column; }
23041 }
23042 .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;}
23043 @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));}}
23044 .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;}
23045 /* ── Result-page chart controls ─────────────────────────────────────────── */
23046 .r-chart-section{margin-bottom:24px;}
23047 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23048 .section-pair > .panel{flex-shrink:0;}
23049 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23050 .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;}
23051 .r-chart-select:focus{border-color:var(--accent);}
23052 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23053 .r-chart-container svg{display:block;width:100%;height:auto;}
23054 .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;}
23055 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23056 .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;}
23057 .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);}
23058 .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;}
23059 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23060 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23061 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23062 .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;}
23063 .r-chart-modal-close:hover{opacity:.7;}
23064 body.dark-theme .r-chart-modal{background:var(--surface);}
23065 .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;}
23066 .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);}
23067 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23068 .lang-bar-row:hover{transform:translateY(-2px);}
23069 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23070 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23071 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23072 .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;}
23073 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23074 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23075 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23076 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23077 #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;}
23078 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23079 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23080 .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;}
23081 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23082 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23083 .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;}
23084 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23085 .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;}
23086 .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;}
23087 body.has-report-banner .top-nav{top:27px;}
23088 body.has-report-banner{padding-bottom:27px;}
23089 </style>
23090</head>
23091<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23092 <div class="background-watermarks" aria-hidden="true">
23093 <img src="/images/logo/logo-text.png" alt="" />
23094 <img src="/images/logo/logo-text.png" alt="" />
23095 <img src="/images/logo/logo-text.png" alt="" />
23096 <img src="/images/logo/logo-text.png" alt="" />
23097 <img src="/images/logo/logo-text.png" alt="" />
23098 <img src="/images/logo/logo-text.png" alt="" />
23099 <img src="/images/logo/logo-text.png" alt="" />
23100 <img src="/images/logo/logo-text.png" alt="" />
23101 <img src="/images/logo/logo-text.png" alt="" />
23102 <img src="/images/logo/logo-text.png" alt="" />
23103 <img src="/images/logo/logo-text.png" alt="" />
23104 <img src="/images/logo/logo-text.png" alt="" />
23105 <img src="/images/logo/logo-text.png" alt="" />
23106 <img src="/images/logo/logo-text.png" alt="" />
23107 </div>
23108 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23109 {% if let Some(banner) = report_header_footer %}
23110 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23111 {% endif %}
23112 <div class="top-nav">
23113 <div class="top-nav-inner">
23114 <a class="brand" href="/">
23115 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23116 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23117 </a>
23118 <div class="nav-project-slot">
23119 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23120 </div>
23121 <div class="nav-status">
23122 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23123 <div class="nav-dropdown">
23124 <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>
23125 <div class="nav-dropdown-menu">
23126 <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>
23127 </div>
23128 </div>
23129 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23130 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23131 <div class="nav-dropdown">
23132 <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>
23133 <div class="nav-dropdown-menu">
23134 <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>
23135 </div>
23136 </div>
23137 <div class="server-status-wrap" id="server-status-wrap">
23138 <div class="nav-pill server-online-pill" id="server-status-pill">
23139 <span class="status-dot" id="status-dot"></span>
23140 <span id="server-status-label">Server</span>
23141 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23142 </div>
23143 <div class="server-status-tip">
23144 OxideSLOC is running — accessible on your network.
23145 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23146 </div>
23147 </div>
23148 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23149 <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>
23150 </button>
23151 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23152 <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>
23153 <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>
23154 </button>
23155 </div>
23156 </div>
23157 </div>
23158
23159 <div class="page">
23160 <section class="hero">
23161 <div class="hero-top">
23162 <div>
23163 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23164 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23165 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23166 <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>
23167 </div>
23168 </div>
23169 <div class="hero-quick-actions">
23170 {% if server_mode %}
23171 <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>
23172 {% else %}
23173 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23174 {% endif %}
23175 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23176 {% if !server_mode %}
23177 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23178 {% endif %}
23179 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23180 <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>
23181 </div>
23182 </div>
23183
23184 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23185 <div class="run-id-row">
23186 <span class="run-id-chip" data-copy="{{ run_id }}">
23187 <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>
23188 <span class="run-id-chip-value">{{ run_id }}</span>
23189 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23190 </span>
23191 {% match git_commit_long %}
23192 {% when Some with (long_sha) %}
23193 {% match git_commit_url %}
23194 {% when Some with (commit_url) %}
23195 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23196 <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>
23197 <span class="run-id-chip-value">{{ long_sha }}</span>
23198 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23199 </a>
23200 {% when None %}
23201 <span class="run-id-chip" data-copy="{{ long_sha }}">
23202 <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>
23203 <span class="run-id-chip-value">{{ long_sha }}</span>
23204 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23205 </span>
23206 {% endmatch %}
23207 {% when None %}
23208 <span class="run-id-chip muted-chip">
23209 <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>
23210 <span class="run-id-chip-value">Not detected</span>
23211 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23212 </span>
23213 {% endmatch %}
23214 {% match git_branch %}
23215 {% when Some with (branch) %}
23216 {% match git_branch_url %}
23217 {% when Some with (branch_url) %}
23218 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23219 <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>
23220 <span class="run-id-chip-value">{{ branch }}</span>
23221 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23222 </a>
23223 {% when None %}
23224 <span class="run-id-chip">
23225 <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>
23226 <span class="run-id-chip-value">{{ branch }}</span>
23227 <span class="chip-tooltip">Git branch active at scan time</span>
23228 </span>
23229 {% endmatch %}
23230 {% when None %}
23231 <span class="run-id-chip muted-chip">
23232 <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>
23233 <span class="run-id-chip-value">Not detected</span>
23234 <span class="chip-tooltip">No Git branch was found for this scan</span>
23235 </span>
23236 {% endmatch %}
23237 {% match git_author %}
23238 {% when Some with (author) %}
23239 <span class="run-id-chip" data-author="{{ author }}">
23240 <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>
23241 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23242 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23243 </span>
23244 {% when None %}
23245 <span class="run-id-chip muted-chip">
23246 <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>
23247 <span class="run-id-chip-value">Not detected</span>
23248 <span class="chip-tooltip">No commit author was found for this scan</span>
23249 </span>
23250 {% endmatch %}
23251 </div>
23252
23253 <!-- Scan metadata row -->
23254 <div class="meta">
23255 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23256 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23257 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23258 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23259 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23260 </div>
23261
23262 <!-- All summary stat chips in one unified strip (8 columns) -->
23263 <div class="summary-strip summary-strip-hero">
23264 <div class="stat-chip" data-raw="{{ physical_lines }}">
23265 <div class="stat-chip-label">Physical lines</div>
23266 <div class="stat-chip-val">{{ physical_lines }}</div>
23267 <div class="stat-chip-exact"></div>
23268 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23269 </div>
23270 <div class="stat-chip" data-raw="{{ code_lines }}">
23271 <div class="stat-chip-label">Code</div>
23272 <div class="stat-chip-val">{{ code_lines }}</div>
23273 <div class="stat-chip-exact"></div>
23274 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23275 </div>
23276 <div class="stat-chip" data-raw="{{ comment_lines }}">
23277 <div class="stat-chip-label">Comments</div>
23278 <div class="stat-chip-val">{{ comment_lines }}</div>
23279 <div class="stat-chip-exact"></div>
23280 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23281 </div>
23282 <div class="stat-chip" data-raw="{{ blank_lines }}">
23283 <div class="stat-chip-label">Blank</div>
23284 <div class="stat-chip-val">{{ blank_lines }}</div>
23285 <div class="stat-chip-exact"></div>
23286 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23287 </div>
23288 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23289 <div class="stat-chip-label">Mixed separate</div>
23290 <div class="stat-chip-val">{{ mixed_lines }}</div>
23291 <div class="stat-chip-exact"></div>
23292 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23293 </div>
23294 <div class="stat-chip" data-raw="{{ functions }}">
23295 <div class="stat-chip-label">Functions</div>
23296 <div class="stat-chip-val">{{ functions }}</div>
23297 <div class="stat-chip-exact"></div>
23298 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23299 </div>
23300 <div class="stat-chip" data-raw="{{ classes }}">
23301 <div class="stat-chip-label">Classes / Types</div>
23302 <div class="stat-chip-val">{{ classes }}</div>
23303 <div class="stat-chip-exact"></div>
23304 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23305 </div>
23306 <div class="stat-chip" data-raw="{{ variables }}">
23307 <div class="stat-chip-label">Variables</div>
23308 <div class="stat-chip-val">{{ variables }}</div>
23309 <div class="stat-chip-exact"></div>
23310 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23311 </div>
23312 <div class="stat-chip" data-raw="{{ imports }}">
23313 <div class="stat-chip-label">Imports</div>
23314 <div class="stat-chip-val">{{ imports }}</div>
23315 <div class="stat-chip-exact"></div>
23316 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23317 </div>
23318 <div class="stat-chip" data-raw="{{ test_count }}">
23319 <div class="stat-chip-label">Tests</div>
23320 <div class="stat-chip-val">{{ test_count }}</div>
23321 <div class="stat-chip-exact"></div>
23322 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23323 </div>
23324 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23325 <div class="stat-chip-label">Code density</div>
23326 <div class="stat-chip-val stat-chip-density-val">—</div>
23327 <div class="stat-chip-exact"></div>
23328 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23329 </div>
23330 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23331 <div class="stat-chip-label">Files analyzed</div>
23332 <div class="stat-chip-val">{{ files_analyzed }}</div>
23333 <div class="stat-chip-exact"></div>
23334 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23335 </div>
23336 {% if cyclomatic_complexity > 0 %}
23337 <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 %}>
23338 <div class="stat-chip-label">Complexity score</div>
23339 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23340 <div class="stat-chip-exact"></div>
23341 <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>
23342 </div>
23343 {% endif %}
23344 {% if let Some(ls) = lsloc %}
23345 <div class="stat-chip" data-raw="{{ ls }}">
23346 <div class="stat-chip-label">Logical SLOC</div>
23347 <div class="stat-chip-val">{{ ls }}</div>
23348 <div class="stat-chip-exact"></div>
23349 <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>
23350 </div>
23351 {% endif %}
23352 {% if uloc > 0 %}
23353 <div class="stat-chip" data-raw="{{ uloc }}">
23354 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23355 <div class="stat-chip-val">{{ uloc }}</div>
23356 <div class="stat-chip-exact"></div>
23357 <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>
23358 </div>
23359 {% endif %}
23360 {% if uloc > 0 && dryness_pct_str != "" %}
23361 <div class="stat-chip">
23362 <div class="stat-chip-label">DRYness</div>
23363 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23364 <div class="stat-chip-exact"></div>
23365 <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>
23366 </div>
23367 {% endif %}
23368 {% if duplicate_group_count > 0 %}
23369 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23370 <div class="stat-chip-label">Duplicate groups</div>
23371 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23372 <div class="stat-chip-exact"></div>
23373 <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>
23374 </div>
23375 {% endif %}
23376 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23377 odd, so the strip always forms exactly two full rows with every column
23378 aligned and every card the same width (no oversized card, no gap). -->
23379 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23380 <div class="stat-chip-label">Assertions</div>
23381 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23382 <div class="stat-chip-exact"></div>
23383 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23384 </div>
23385 </div>
23386
23387 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23388 <div class="compare-banner">
23389 <div class="compare-banner-body">
23390 <div class="compare-banner-top">
23391 <div class="compare-banner-meta">
23392 <span class="compare-label">Previous scan</span>
23393 <span class="compare-ts">{{ prev_ts }}</span>
23394 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23395 {% if let Some(prev_code) = prev_run_code_lines %}
23396 <div class="compare-banner-stats" style="margin-top:4px;">
23397 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23398 <span class="compare-arrow">→</span>
23399 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23400 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23401 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23402 </div>
23403 {% endif %}
23404 </div>
23405 {% if delta_lines_added.is_some() %}
23406 <div class="delta-cards-inline">
23407 <div class="delta-card-inline">
23408 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23409 <div class="delta-card-lbl">lines added</div>
23410 <div class="delta-card-tip">Code lines added since the previous scan</div>
23411 </div>
23412 <div class="delta-card-inline">
23413 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23414 <div class="delta-card-lbl">lines removed</div>
23415 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23416 </div>
23417 <div class="delta-card-inline">
23418 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23419 <div class="delta-card-lbl">unmodified lines</div>
23420 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23421 </div>
23422 <div class="delta-card-inline">
23423 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23424 <div class="delta-card-lbl">files modified</div>
23425 <div class="delta-card-tip">Files with at least one line changed</div>
23426 </div>
23427 <div class="delta-card-inline">
23428 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23429 <div class="delta-card-lbl">files added</div>
23430 <div class="delta-card-tip">New files added since the previous scan</div>
23431 </div>
23432 <div class="delta-card-inline">
23433 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23434 <div class="delta-card-lbl">files removed</div>
23435 <div class="delta-card-tip">Files deleted since the previous scan</div>
23436 </div>
23437 <div class="delta-card-inline">
23438 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23439 <div class="delta-card-lbl">files unchanged</div>
23440 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23441 </div>
23442 </div>
23443 {% else %}
23444 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23445 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23446 </p>
23447 {% endif %}
23448 </div>
23449 <div class="compare-banner-actions">
23450 <div class="compare-banner-actions-left">
23451 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23452 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23453 </div>
23454 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23455 </div>
23456 </div>
23457 </div>
23458 {% endif %}{% endif %}
23459
23460 <div class="action-grid">
23461 <div class="action-card">
23462 <h3>HTML report</h3>
23463 <div class="action-buttons">
23464 {% match html_url %}
23465 {% when Some with (url) %}
23466 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23467 {% when None %}{% endmatch %}
23468 {% match html_download_url %}
23469 {% when Some with (url) %}
23470 <a class="button secondary" href="{{ url }}">Download HTML</a>
23471 {% when None %}{% endmatch %}
23472 {% match html_path %}
23473 {% when Some with (_path) %}{% when None %}{% endmatch %}
23474 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23475 </div>
23476 </div>
23477 <div class="action-card">
23478 <h3>PDF report</h3>
23479 <div class="action-buttons">
23480 {% match pdf_url %}
23481 {% when Some with (url) %}
23482 {% if pdf_generating %}
23483 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23484 <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>
23485 Generating PDF…
23486 </button>
23487 {% else %}
23488 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23489 {% endif %}
23490 {% when None %}
23491 {% match html_url %}
23492 {% when Some with (_hurl) %}
23493 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23494 <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>
23495 {% when None %}
23496 <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;">
23497 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23498 </p>
23499 {% endmatch %}
23500 {% endmatch %}
23501 {% match pdf_download_url %}
23502 {% when Some with (url) %}
23503 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23504 {% when None %}{% endmatch %}
23505 {% match pdf_url %}
23506 {% when Some with (_) %}
23507 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23508 {% when None %}{% endmatch %}
23509 </div>
23510 </div>
23511 <div class="action-card">
23512 <h3>JSON result</h3>
23513 <div class="action-buttons">
23514 {% match json_url %}
23515 {% when Some with (url) %}
23516 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23517 {% when None %}{% endmatch %}
23518 {% match json_download_url %}
23519 {% when Some with (url) %}
23520 <a class="button secondary" href="{{ url }}">Download JSON</a>
23521 {% when None %}{% endmatch %}
23522 {% match json_path %}
23523 {% when Some with (_path) %}
23524 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23525 {% when None %}
23526 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23527 {% endmatch %}
23528 </div>
23529 </div>
23530 <div class="action-card">
23531 <h3>Scan config</h3>
23532 <div class="action-buttons">
23533 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23534 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23535 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23536 </div>
23537 </div>
23538 {% if confluence_configured %}
23539 <div class="action-card" id="confluenceCard">
23540 <h3>Confluence</h3>
23541 <div class="action-buttons">
23542 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23543 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23544 </div>
23545 <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>
23546 </div>
23547 {% endif %}
23548 </div>
23549 {% if confluence_configured %}
23550 <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;">
23551 <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);">
23552 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23553 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23554 <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;">
23555 <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>
23556 <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;">
23557 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23558 <div style="display:flex;gap:10px;justify-content:flex-end;">
23559 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23560 <button class="button" id="confSubmitBtn" type="button">Post</button>
23561 </div>
23562 </div>
23563 </div>
23564 {% endif %}
23565 <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;">
23566 <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);">
23567 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23568 <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>
23569 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23570 <div style="display:flex;gap:18px;justify-content:flex-end;">
23571 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23572 <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>
23573 </div>
23574 </div>
23575 </div>
23576 {% if !submodule_rows.is_empty() %}
23577 <div class="submodule-panel">
23578 <div class="toolbar-row">
23579 <div>
23580 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23581 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23582 </div>
23583 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23584 </div>
23585 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23586 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23587 <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>
23588 <thead>
23589 <tr>
23590 <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>
23591 <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>
23592 <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>
23593 <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>
23594 <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>
23595 <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>
23596 <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>
23597 <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>
23598 </tr>
23599 </thead>
23600 <tbody>
23601 {% for row in submodule_rows %}
23602 <tr>
23603 <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>
23604 <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>
23605 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23606 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23607 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23608 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23609 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23610 <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>
23611 </tr>
23612 {% endfor %}
23613 </tbody>
23614 </table>
23615 </div>
23616 </div>
23617 {% endif %}
23618
23619 <div class="metrics-tables-stack">
23620
23621 <div class="metrics-table-wrap">
23622 <div class="metrics-table-title">Files</div>
23623 <table class="metrics-table">
23624 <thead>
23625 <tr>
23626 <th>Metric</th>
23627 <th>This Run</th>
23628 <th>Previous</th>
23629 <th>Change</th>
23630 </tr>
23631 </thead>
23632 <tbody>
23633 <tr>
23634 <td>Files analyzed</td>
23635 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23636 <td>{{ prev_fa_str|commas }}</td>
23637 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23638 </tr>
23639 <tr>
23640 <td>Files skipped</td>
23641 <td>{{ files_skipped|commas }}</td>
23642 <td>{{ prev_fs_str|commas }}</td>
23643 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23644 </tr>
23645 <tr>
23646 <td>Files modified</td>
23647 <td class="mt-val-na">—</td>
23648 <td class="mt-val-na">—</td>
23649 <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>
23650 </tr>
23651 <tr>
23652 <td>Files unchanged</td>
23653 <td class="mt-val-na">—</td>
23654 <td class="mt-val-na">—</td>
23655 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23656 </tr>
23657 </tbody>
23658 </table>
23659 </div>
23660
23661 <div class="metrics-table-wrap">
23662 <div class="metrics-table-title">Line Counts</div>
23663 <table class="metrics-table">
23664 <thead>
23665 <tr>
23666 <th>Metric</th>
23667 <th>This Run</th>
23668 <th>Previous</th>
23669 <th>Change</th>
23670 </tr>
23671 </thead>
23672 <tbody>
23673 <tr>
23674 <td>Physical lines</td>
23675 <td class="mt-val-large">{{ physical_lines|commas }}</td>
23676 <td>{{ prev_pl_str|commas }}</td>
23677 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23678 </tr>
23679 <tr>
23680 <td>Code lines</td>
23681 <td class="mt-val-large">{{ code_lines|commas }}</td>
23682 <td>{{ prev_cl_str|commas }}</td>
23683 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23684 </tr>
23685 <tr>
23686 <td>Comment lines</td>
23687 <td>{{ comment_lines|commas }}</td>
23688 <td>{{ prev_cml_str|commas }}</td>
23689 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23690 </tr>
23691 <tr>
23692 <td>Blank lines</td>
23693 <td>{{ blank_lines|commas }}</td>
23694 <td>{{ prev_bl_str|commas }}</td>
23695 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23696 </tr>
23697 <tr>
23698 <td>Mixed (separate)</td>
23699 <td>{{ mixed_lines|commas }}</td>
23700 <td class="mt-val-na">—</td>
23701 <td class="mt-val-na">—</td>
23702 </tr>
23703 </tbody>
23704 </table>
23705 </div>
23706
23707 <div class="metrics-tables-lower">
23708 <div class="metrics-table-wrap">
23709 <div class="metrics-table-title">Code Structure</div>
23710 <table class="metrics-table">
23711 <thead>
23712 <tr>
23713 <th>Metric</th>
23714 <th>This Run</th>
23715 </tr>
23716 </thead>
23717 <tbody>
23718 <tr>
23719 <td>Functions</td>
23720 <td>{{ functions|commas }}</td>
23721 </tr>
23722 <tr>
23723 <td>Classes / Types</td>
23724 <td>{{ classes|commas }}</td>
23725 </tr>
23726 <tr>
23727 <td>Variables</td>
23728 <td>{{ variables|commas }}</td>
23729 </tr>
23730 <tr>
23731 <td>Imports</td>
23732 <td>{{ imports|commas }}</td>
23733 </tr>
23734 </tbody>
23735 </table>
23736 </div>
23737
23738 <div class="metrics-table-wrap">
23739 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23740 <table class="metrics-table">
23741 <thead>
23742 <tr>
23743 <th>Metric</th>
23744 <th>Change</th>
23745 </tr>
23746 </thead>
23747 <tbody>
23748 <tr>
23749 <td>Lines added</td>
23750 <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>
23751 </tr>
23752 <tr>
23753 <td>Lines removed</td>
23754 <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>
23755 </tr>
23756 <tr>
23757 <td>Lines modified (net)</td>
23758 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23759 </tr>
23760 <tr>
23761 <td>Lines unmodified</td>
23762 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23763 </tr>
23764 </tbody>
23765 </table>
23766 </div>
23767 </div>
23768
23769 </div>
23770
23771 <div class="path-list">
23772 <div class="path-item">
23773 <div class="path-item-label">Project path</div>
23774 {% 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 %}
23775 </div>
23776 <div class="path-item">
23777 <div class="path-item-label">Git branch</div>
23778 {% if let Some(branch) = git_branch %}
23779 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23780 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23781 {% else %}
23782 <code style="color:var(--muted)">—</code>
23783 {% endif %}
23784 </div>
23785 <div class="path-item">
23786 <div class="path-item-label">Output folder</div>
23787 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23788 </div>
23789 <div class="path-item">
23790 <div class="path-item-label">Run ID</div>
23791 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23792 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23793 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23794 </div>
23795 </div>
23796 </div>
23797 </section>
23798
23799 {% if has_cocomo %}
23800 <div class="cocomo-box" style="margin-top:24px;">
23801 <div class="cocomo-box-head">
23802 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
23803 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23804 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23805 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23806 </span>
23807 </div>
23808 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23809 <div class="stat-chip">
23810 <div class="stat-chip-label">Person-months</div>
23811 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23812 <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>
23813 </div>
23814 <div class="stat-chip">
23815 <div class="stat-chip-label">Schedule (months)</div>
23816 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23817 <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>
23818 </div>
23819 <div class="stat-chip">
23820 <div class="stat-chip-label">Avg. Team Size</div>
23821 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23822 <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>
23823 </div>
23824 <div class="stat-chip">
23825 <div class="stat-chip-label">Input KSLOC</div>
23826 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23827 <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>
23828 </div>
23829 </div>
23830 <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>
23831 </div>
23832 {% endif %}
23833
23834 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23835 <div class="cocomo-box" style="margin-top:24px;">
23836 <div class="cocomo-box-head">
23837 <span class="cocomo-box-title">Tests & Coverage</span>
23838 {% if has_coverage_data %}
23839 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23840 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23841 </span>
23842 {% endif %}
23843 </div>
23844 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23845 <div class="stat-chip">
23846 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23847 <div class="stat-chip-label">Test Functions</div>
23848 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23849 </div>
23850 <div class="stat-chip">
23851 {% if has_coverage_data %}
23852 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23853 {% else %}
23854 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23855 {% endif %}
23856 <div class="stat-chip-label">Line Coverage</div>
23857 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23858 </div>
23859 <div class="stat-chip">
23860 {% if !cov_fn_pct.is_empty() %}
23861 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23862 {% else %}
23863 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23864 {% endif %}
23865 <div class="stat-chip-label">Fn Coverage</div>
23866 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23867 </div>
23868 <div class="stat-chip">
23869 {% if !cov_branch_pct.is_empty() %}
23870 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23871 {% else %}
23872 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23873 {% endif %}
23874 <div class="stat-chip-label">Branch Coverage</div>
23875 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23876 </div>
23877 </div>
23878 {% if has_coverage_data %}
23879 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
23880 {% else %}
23881 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
23882 {% endif %}
23883 </div>
23884
23885 <div class="section-pair">
23886 <section class="panel">
23887 <div class="toolbar-row">
23888 <div>
23889 <h2>Language Breakdown</h2>
23890 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23891 </div>
23892 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23893 </div>
23894 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23895 </section>
23896
23897 <section class="panel r-chart-section">
23898 <div class="toolbar-row" style="margin-bottom:16px;">
23899 <div>
23900 <h2>Visualizations</h2>
23901 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23902 </div>
23903 </div>
23904
23905 <div class="r-viz-grid">
23906 <div class="r-viz-card">
23907 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23908 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23909 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23910 </div>
23911 <div class="r-chart-tab-bar">
23912 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23913 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23914 </div>
23915 <div class="r-chart-container" id="r-composition-chart"></div>
23916 </div>
23917 <div class="r-viz-card">
23918 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23919 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23920 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23921 </div>
23922 <div class="r-chart-container" id="r-scatter-chart"></div>
23923 </div>
23924 {% if has_semantic_data %}
23925 <div class="r-viz-card">
23926 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23927 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23928 <select class="r-chart-select" id="r-semantic-metric">
23929 <option value="functions">Functions</option>
23930 <option value="classes">Classes</option>
23931 <option value="variables">Variables</option>
23932 <option value="imports">Imports</option>
23933 <option value="tests">Tests</option>
23934 </select>
23935 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23936 </div>
23937 <div class="r-chart-container" id="r-semantic-chart"></div>
23938 </div>
23939 {% endif %}
23940 <div class="r-viz-card">
23941 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23942 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23943 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23944 </div>
23945 <div class="r-chart-container" id="r-density-chart"></div>
23946 </div>
23947 <div class="r-viz-card">
23948 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23949 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23950 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23951 </div>
23952 <div class="r-chart-container" id="r-avglines-chart"></div>
23953 </div>
23954 <div class="r-viz-card">
23955 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23956 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23957 <select class="r-chart-select" id="r-sub-metric">
23958 <option value="code">Code Lines</option>
23959 <option value="comment">Comments</option>
23960 <option value="blank">Blank Lines</option>
23961 <option value="physical">Physical Lines</option>
23962 <option value="files">Files</option>
23963 </select>
23964 <select class="r-chart-select" id="r-sub-sort">
23965 <option value="desc">Value ↓</option>
23966 <option value="asc">Value ↑</option>
23967 <option value="name">Name A→Z</option>
23968 </select>
23969 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23970 </div>
23971 <div class="r-chart-container" id="r-submodule-chart"></div>
23972 </div>
23973 </div>
23974
23975 </section>
23976 </div>
23977
23978 </div>
23979
23980 <div id="r-tt" aria-hidden="true"></div>
23981
23982 <script nonce="{{ csp_nonce }}">
23983 (function () {
23984 var body = document.body;
23985 var themeToggle = document.getElementById('theme-toggle');
23986 var storageKey = 'oxide-sloc-theme';
23987
23988 function applyTheme(theme) {
23989 body.classList.toggle('dark-theme', theme === 'dark');
23990 }
23991
23992 function loadSavedTheme() {
23993 try {
23994 var saved = localStorage.getItem(storageKey);
23995 if (saved === 'dark' || saved === 'light') {
23996 applyTheme(saved);
23997 }
23998 } catch (e) {}
23999 }
24000
24001 if (themeToggle) {
24002 themeToggle.addEventListener('click', function () {
24003 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24004 applyTheme(nextTheme);
24005 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24006 });
24007 }
24008
24009 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24010 button.addEventListener('click', function () {
24011 var value = button.getAttribute('data-copy-value') || '';
24012 if (!value) return;
24013 var originalText = button.textContent;
24014 function flashSuccess() {
24015 button.textContent = 'Copied!';
24016 setTimeout(function () { button.textContent = originalText; }, 1800);
24017 }
24018 function flashFail() {
24019 button.textContent = 'Copy failed';
24020 setTimeout(function () { button.textContent = originalText; }, 2000);
24021 }
24022 if (navigator.clipboard && navigator.clipboard.writeText) {
24023 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24024 fallbackCopy(value, flashSuccess, flashFail);
24025 });
24026 } else {
24027 fallbackCopy(value, flashSuccess, flashFail);
24028 }
24029 });
24030 });
24031 function fallbackCopy(text, onSuccess, onFail) {
24032 try {
24033 var ta = document.createElement('textarea');
24034 ta.value = text;
24035 ta.style.position = 'fixed';
24036 ta.style.top = '-9999px';
24037 ta.style.left = '-9999px';
24038 document.body.appendChild(ta);
24039 ta.focus();
24040 ta.select();
24041 var ok = document.execCommand('copy');
24042 document.body.removeChild(ta);
24043 if (ok) { onSuccess(); } else { onFail(); }
24044 } catch (e) { onFail(); }
24045 }
24046
24047 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24048 btn.addEventListener('click', function () {
24049 var folder = btn.getAttribute('data-folder') || '';
24050 if (!folder) return;
24051 var orig = btn.textContent;
24052 fetch('/open-path?path=' + encodeURIComponent(folder))
24053 .then(function (r) { return r.json(); })
24054 .then(function (d) {
24055 if (d && d.server_mode_disabled) {
24056 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24057 } else if (d && d.ok) {
24058 btn.textContent = 'Opened!';
24059 setTimeout(function () { btn.textContent = orig; }, 1800);
24060 }
24061 })
24062 .catch(function () {
24063 btn.textContent = 'Failed';
24064 setTimeout(function () { btn.textContent = orig; }, 2000);
24065 });
24066 });
24067 });
24068
24069 loadSavedTheme();
24070
24071 // ── Compact number formatting for stat chips ──────────────────────────
24072 (function(){
24073 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();}
24074 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24075 var raw=parseInt(chip.getAttribute('data-raw'),10);
24076 if(isNaN(raw))return;
24077 var valEl=chip.querySelector('.stat-chip-val');
24078 if(valEl)valEl.textContent=fmt(raw);
24079 var exactEl=chip.querySelector('.stat-chip-exact');
24080 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24081 });
24082 // Code density chip
24083 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24084 var code=parseInt(chip.getAttribute('data-code'),10);
24085 var phys=parseInt(chip.getAttribute('data-physical'),10);
24086 if(isNaN(code)||isNaN(phys)||phys===0)return;
24087 var pct=(code/phys*100).toFixed(1)+'%';
24088 var valEl=chip.querySelector('.stat-chip-val');
24089 if(valEl)valEl.textContent=pct;
24090 });
24091 // Populate author handle from data-author attribute
24092 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24093 var author=chip.getAttribute('data-author');
24094 var el=chip.querySelector('.author-handle');
24095 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24096 });
24097 // Click-to-copy on run-id-chip elements
24098 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24099 chip.addEventListener('click',function(){
24100 var val=chip.getAttribute('data-copy');
24101 if(!val)return;
24102 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24103 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);}
24104 chip.classList.add('chip-copied-flash');
24105 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24106 });
24107 });
24108 // Format delta card values with data-raw using comma-separated full numbers
24109 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24110 var raw=parseInt(card.getAttribute('data-raw'),10);
24111 if(isNaN(raw))return;
24112 var valEl=card.querySelector('.delta-card-val');
24113 if(valEl)valEl.textContent=raw.toLocaleString();
24114 });
24115 // Format code-before / code-now numbers in the compare banner stats line
24116 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24117 var raw=parseInt(el.getAttribute('data-raw'),10);
24118 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24119 });
24120 })();
24121
24122 // ── Shared tooltip for all result-page charts ─────────────────────────
24123 var rTT=(function(){
24124 var el=document.getElementById('r-tt');
24125 if(!el)return{s:function(){},h:function(){},m:function(){}};
24126 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24127 function hide(){el.style.display='none';}
24128 function move(e){
24129 var x=e.clientX+16,y=e.clientY-12;
24130 var r=el.getBoundingClientRect();
24131 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24132 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24133 el.style.left=x+'px';el.style.top=y+'px';
24134 }
24135 return{s:show,h:hide,m:move};
24136 })();
24137 window.rTT=rTT;
24138
24139 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24140 (function(){
24141 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24142 document.addEventListener('mouseover',function(e){
24143 var t=e.target;
24144 while(t&&t.getAttribute){
24145 var l=t.getAttribute('data-ttl');
24146 if(l!==null){
24147 var v=t.getAttribute('data-ttv')||'';
24148 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24149 return;
24150 }
24151 t=t.parentNode;
24152 }
24153 });
24154 document.addEventListener('mouseout',function(e){
24155 var t=e.target;
24156 while(t&&t.getAttribute){
24157 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24158 t=t.parentNode;
24159 }
24160 });
24161 document.addEventListener('mousemove',function(e){
24162 var el=document.getElementById('r-tt');
24163 if(el&&el.style.display!=='none')rTT.m(e);
24164 });
24165 window.addEventListener('blur',function(){rTT.h();});
24166 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24167 })();
24168
24169 // ── Language overview charts ───────────────────────────────────────────
24170 (function(){
24171 var D={{ lang_chart_json|safe }};
24172 if(!D||!D.length)return;
24173 var el=document.getElementById('result-lang-charts');
24174 if(!el)return;
24175 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24176 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24177 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24178 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();}
24179 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24180 function px(n){return Math.round(n);}
24181 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+'"';}
24182 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24183 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24184 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24185 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;}
24186 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24187
24188 // Donut chart — height matches the stacked-bar chart so both panels align
24189 var rHb_d=28;
24190 var DH=Math.max(220,D.length*rHb_d+32);
24191 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24192 var legX=208,DW=395;
24193 var legCount=D.length;
24194 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24195 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24196 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">';
24197 // One shared transition on every donut element so slices, leader lines,
24198 // outside labels, % labels and the legend all animate together as a single
24199 // picture when a language is hovered. Slices scale from the donut centre.
24200 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>';
24201 if(D.length===1){
24202 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24203 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+'"/>';
24204 } else {
24205 var smalls=[];
24206 var ang=-Math.PI/2;
24207 D.forEach(function(d,i){
24208 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24209 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24210 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24211 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24212 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24213 var pct=Math.round(d.code/tot*100);
24214 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"/>';
24215 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]});}
24216 ang+=sw;
24217 });
24218 // Small slices (<5%) get outside labels positioned near each slice's own
24219 // angular position (a slice on the left gets its label/leader on the left),
24220 // then nudged apart horizontally so text never overlaps. Leader lines point
24221 // from each slice to its label. Horizontal text keeps long names legible;
24222 // the whole SVG scales up in Full View so these stay readable there too.
24223 if(smalls.length){
24224 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24225 var sPad=6,sRowY=11;
24226 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)));});
24227 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;}
24228 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24229 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24230 smalls.forEach(function(sm){
24231 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24232 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;"/>';
24233 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>';
24234 });
24235 }
24236 }
24237 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24238 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24239 D.forEach(function(d,i){
24240 var ly=legYStart+i*legSpacing;
24241 var pctL=Math.round(d.code/tot*100);
24242 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24243 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24244 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24245 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24246 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24247 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24248 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>';
24249 ds+='</g>';
24250 });
24251 ds+='</svg>';
24252
24253 // Horizontal stacked-bar chart — fills container width
24254 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24255 var LW=108,BW=260,svgW=LW+BW+68;
24256 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24257 var barBH=Math.min(32,Math.round(barRhb*0.7));
24258 var SH=DH;
24259 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24260 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">';
24261 D.forEach(function(d,i){
24262 var y=barTopPad+i*barRhb,x=LW;
24263 var phys=d.physical||d.code+d.comments+d.blanks;
24264 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24265 var lmid=y+barBH/2+4;
24266 // Combined breakdown shown when hovering the row, the language name, or the
24267 // total at the bar end (\n becomes a line break in the tooltip).
24268 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24269 bs+='<g class="lang-bar-row">';
24270 // Hit area ends just past the total label so empty space to the right of the
24271 // bar does not trigger the tooltip — only the name, bar and total are hot.
24272 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24273 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24274 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>';
24275 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;}
24276 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;}
24277 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>';}
24278 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>';
24279 bs+='</g>';
24280 });
24281 var ly=SH-14;
24282 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24283 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24284 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24285 var totAll=totC+totCm+totBl||1;
24286 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24287 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24288 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24289 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24290 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24291 bs+='<g data-kind="code" style="cursor:pointer;">'
24292 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24293 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24294 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24295 +'</g>';
24296 bs+='<g data-kind="comment" style="cursor:pointer;">'
24297 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24298 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24299 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24300 +'</g>';
24301 bs+='<g data-kind="blank" style="cursor:pointer;">'
24302 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24303 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24304 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24305 +'</g>';
24306 bs+='</svg>';
24307 el.innerHTML='<div class="r-lang-overview">'+
24308 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24309 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24310 '</div>';
24311 function wireDonutLegend(svg){
24312 if(!svg)return;
24313 // Every donut element carries data-lang: slices (path/circle), leader lines,
24314 // outside labels + % labels (text) and legend rows (g). Hovering any one of
24315 // them emphasises that language across all of them and fades the rest, so the
24316 // slice, its leader line, its label and its legend row move as one picture.
24317 var items=svg.querySelectorAll('[data-lang]');
24318 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24319 var tag=el.tagName.toLowerCase();
24320 if(tag==='path'||tag==='circle'){
24321 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)';}
24322 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24323 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24324 }else if(tag==='line'){
24325 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24326 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24327 else{el.style.opacity='';el.style.strokeWidth='';}
24328 }else if(tag==='text'){
24329 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24330 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24331 else{el.style.opacity='';el.style.fontWeight='';}
24332 }else{ // legend group
24333 if(st===1){el.style.opacity='1';}
24334 else if(st===-1){el.style.opacity='0.4';}
24335 else{el.style.opacity='';}
24336 }
24337 }
24338 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24339 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24340 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();});
24341 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();});
24342 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24343 }
24344 function wireMixLegend(svg){
24345 if(!svg)return;
24346 var legGs=svg.querySelectorAll('g[data-kind]');
24347 var allRects=svg.querySelectorAll('rect[data-kind]');
24348 if(!legGs.length)return;
24349 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';}}
24350 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='';}}
24351 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]);}
24352 }
24353 wireDonutLegend(el.querySelector('svg'));
24354 wireMixLegend(el.querySelectorAll('svg')[1]);
24355
24356 // ── Language breakdown Full View expand ─────────────────────────────────
24357 var langOvBtn=document.getElementById('result-lang-overview-expand');
24358 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24359 var src=document.getElementById('result-lang-charts');
24360 if(!src)return;
24361 var overlay=document.createElement('div');
24362 overlay.className='r-chart-modal-overlay';
24363 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>';
24364 document.body.appendChild(overlay);
24365 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24366 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24367 var wrap=document.getElementById('result-lang-overview-modal-wrap');
24368 if(wrap){
24369 wrap.innerHTML=src.innerHTML;
24370 var svgs=wrap.querySelectorAll('svg');
24371 for(var i=0;i<svgs.length;i++){
24372 svgs[i].removeAttribute('width');
24373 svgs[i].removeAttribute('height');
24374 svgs[i].style.cssText='display:block;width:100%;height:auto;';
24375 }
24376 var ov=wrap.querySelector('.r-lang-overview');
24377 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24378 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24379 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24380 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24381 wireDonutLegend(wrap.querySelector('svg'));
24382 wireMixLegend(wrap.querySelectorAll('svg')[1]);
24383 requestAnimationFrame(function(){
24384 var ss=wrap.querySelectorAll('svg');
24385 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%;';}}
24386 });
24387 }
24388 });}
24389 })();
24390
24391 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24392 (function(){
24393 var LANG_D={{ lang_chart_json|safe }};
24394 var SCAT_D={{ scatter_chart_json|safe }};
24395 var SEM_D={{ semantic_chart_json|safe }};
24396 var SUB_D={{ submodule_chart_json|safe }};
24397 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24398 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24399 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();}
24400 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24401 function px(n){return Math.round(n);}
24402 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+'"';}
24403 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24404 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24405 // rather than disappear; the SVG scales up in Full View).
24406 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;}
24407
24408 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24409 function renderCompositionInEl(el,mode,shOvr){
24410 if(!el||!LANG_D||!LANG_D.length)return;
24411 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24412 var LW=110,SH=shOvr||300;
24413 var svgW=Math.max(320,el.offsetWidth||480);
24414 var BW=Math.max(120,svgW-LW-80);
24415 var legendH=24,topPad=4;
24416 var n=LANG_D.length||1;
24417 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24418 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24419 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">';
24420 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24421 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24422 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24423 var totAll2=totC2+totCm2+totBl2||1;
24424 if(mode==='pct'){
24425 LANG_D.forEach(function(d,i){
24426 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24427 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24428 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24429 var lmid=y+Math.floor(bH/2)+4;
24430 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24431 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>';
24432 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;}
24433 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;}
24434 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>';}
24435 var pct=Math.round((d.code||0)/tot2*100);
24436 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>';
24437 });
24438 } else {
24439 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24440 LANG_D.forEach(function(d,i){
24441 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24442 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24443 var lmid=y+Math.floor(bH/2)+4;
24444 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));
24445 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>';
24446 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;}
24447 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;}
24448 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>';}
24449 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>';
24450 });
24451 }
24452 var ly=SH-legendH+4;
24453 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24454 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24455 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24456 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24457 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24458 s+='<g data-kind="code" style="cursor:pointer;">'
24459 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24460 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24461 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24462 +'</g>';
24463 s+='<g data-kind="comment" style="cursor:pointer;">'
24464 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24465 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24466 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24467 +'</g>';
24468 s+='<g data-kind="blank" style="cursor:pointer;">'
24469 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24470 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24471 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24472 +'</g>';
24473 s+='</svg>';
24474 el.innerHTML=s;
24475 wireMixLegendEl(el);
24476 }
24477 function wireMixLegendEl(container){
24478 var svg=container&&container.querySelector('svg');
24479 if(!svg)return;
24480 var legGs=svg.querySelectorAll('g[data-kind]');
24481 var allRects=svg.querySelectorAll('rect[data-kind]');
24482 if(!legGs.length)return;
24483 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';}}
24484 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='';}}
24485 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]);}
24486 }
24487 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24488 renderComposition('abs');
24489 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24490 btn.addEventListener('click',function(){
24491 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24492 btn.classList.add('active');
24493 renderComposition(btn.getAttribute('data-rcomp'));
24494 });
24495 });
24496
24497 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24498 function wireScatterLegend(container){
24499 var svg=container&&container.querySelector('svg');
24500 if(!svg)return;
24501 var legGs=svg.querySelectorAll('g[data-lang]');
24502 var circs=svg.querySelectorAll('circle[data-lang]');
24503 var labs=svg.querySelectorAll('text[data-lang]');
24504 if(!legGs.length)return;
24505 // Raise an element to the top of its parent so the hovered bubble and its
24506 // name/number labels sit above overlapping neighbours (clustered bubbles
24507 // otherwise bury the one you are trying to read).
24508 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24509 function hl(lang){
24510 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';}}
24511 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';}}
24512 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24513 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='';}}
24514 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]);}
24515 }
24516 function renderScatterInEl(el,hOvr){
24517 if(!el||!SCAT_D||!SCAT_D.length)return;
24518 var n=SCAT_D.length;
24519 var H=hOvr||300,PL=52,PB=36,PT=44;
24520 var W=Math.max(320,el.offsetWidth||480);
24521 var cH=H-PT-PB;
24522 // Legend: max 2 columns, fills vertical space. The compact card shows the
24523 // top languages by code lines plus a "+N more" row linking to Full View;
24524 // Full View (hOvr set) shows every language across up to 2 tall columns.
24525 var compact=!hOvr;
24526 var availH=Math.max(120,H-24);
24527 var rowsFit=Math.max(2,Math.floor(availH/18));
24528 var legTrunc=compact&&(n>2*rowsFit);
24529 var legShown=legTrunc?(2*rowsFit-1):n;
24530 var legTotal=legTrunc?(2*rowsFit):n;
24531 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24532 var legPerCol=Math.ceil(legTotal/legCols);
24533 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24534 var legColW=hOvr?144:130;
24535 var LG=26;
24536 var legW=legCols*legColW;
24537 var cW=W-PL-LG-legW;
24538 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24539 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24540 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24541 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24542 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24543 var logMaxF=Math.log1p(maxF);
24544 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">';
24545 // Smooth the legend-hover fade so bubbles + labels animate together.
24546 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24547 // Y grid lines (linear)
24548 [0,0.25,0.5,0.75,1].forEach(function(t){
24549 var y=PT+cH*(1-t);
24550 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24551 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>';
24552 });
24553 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24554 [0,0.25,0.5,0.75,1].forEach(function(t){
24555 var x=PL+cW*t;
24556 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24557 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24558 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>';
24559 });
24560 // Full View (hOvr set) has the vertical room to show the per-bubble value
24561 // line; the compact card shows only the language label to avoid the
24562 // overlapping-label clutter seen when bubbles cluster together.
24563 var showVal=!!hOvr;
24564 SCAT_D.forEach(function(d,i){
24565 // X uses log1p so outlier languages (many files) don't push others to the far left
24566 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24567 var cy2=PT+cH-d.code/maxC*cH;
24568 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24569 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"/>';
24570 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24571 if(showVal){
24572 var ty2=Math.max(24,px(cy2)-px(r)-3);
24573 var ty1=Math.max(12,ty2-14);
24574 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>';
24575 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>';
24576 }else{
24577 var ly2=Math.max(12,px(cy2)-px(r)-3);
24578 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>';
24579 }
24580 });
24581 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>';
24582 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>';
24583 // Legend (right side — top languages, max 2 columns, fills height)
24584 var legX=PL+cW+LG;
24585 var legBlockH=legPerCol*legRowH;
24586 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24587 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24588 for(var lk=0;lk<legShown;lk++){
24589 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24590 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24591 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;">';
24592 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24593 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24594 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>';
24595 s+='</g>';
24596 }
24597 if(legTrunc){
24598 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24599 s+='<g data-more="1" style="cursor:pointer;">';
24600 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24601 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24602 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>';
24603 s+='</g>';
24604 }
24605 s+='</svg>';
24606 el.innerHTML=s;
24607 wireScatterLegend(el);
24608 var moreEl=el.querySelector('g[data-more]');
24609 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24610 }
24611 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24612
24613 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24614 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24615 // the old vertical column layout on wide containers.
24616 function renderSemanticInEl(el,key,sh){
24617 if(!el||!SEM_D||!SEM_D.length)return;
24618 var n2=SEM_D.length||1;
24619 var LW=112,SH=sh||Math.max(180,n2*28+26);
24620 var svgW=Math.max(320,el.offsetWidth||480);
24621 var BW=Math.max(120,svgW-LW-80);
24622 var topPad=4,botPad=14;
24623 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24624 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24625 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24626 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">';
24627 SEM_D.forEach(function(d,i){
24628 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24629 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>';
24630 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"/>';
24631 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>';
24632 });
24633 s+='</svg>';
24634 el.innerHTML=s;
24635 }
24636 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24637 var semSel=document.getElementById('r-semantic-metric');
24638 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24639 var semExpand=document.getElementById('r-semantic-expand');
24640 if(semExpand){
24641 semExpand.addEventListener('click',function(){
24642 var key=semSel?semSel.value:'functions';
24643 var n=SEM_D.length||1;
24644 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24645 var modalH=Math.min(Math.max(360,n*38+60),maxH);
24646 var overlay=document.createElement('div');
24647 overlay.className='r-chart-modal-overlay';
24648 var optHtml=
24649 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24650 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24651 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24652 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24653 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24654 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>';
24655 document.body.appendChild(overlay);
24656 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24657 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24658 var modalEl=document.getElementById('r-sem-modal-chart');
24659 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24660 var modalSel=document.getElementById('r-sem-modal-metric');
24661 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24662 });
24663 }
24664
24665 // ── Expand buttons: re-render charts at large size inside modal ──────────
24666 (function(){
24667 function makeExpandModal(title,mH,subtitle,ctrlHtml){
24668 var overlay=document.createElement('div');
24669 overlay.className='r-chart-modal-overlay';
24670 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24671 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24672 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>';
24673 document.body.appendChild(overlay);
24674 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24675 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24676 return overlay.querySelector('.r-expand-modal-chart');
24677 }
24678 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24679 var compExpandBtn=document.getElementById('r-composition-expand');
24680 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24681 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24682 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24683 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24684 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24685 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24686 if(wrap){
24687 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24688 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24689 btn.addEventListener('click',function(){
24690 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24691 btn.classList.add('active');
24692 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24693 });
24694 });
24695 }
24696 });}
24697 var scatExpandBtn=document.getElementById('r-scatter-expand');
24698 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24699 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24700 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24701 });}
24702 var densExpandBtn=document.getElementById('r-density-expand');
24703 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24704 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24705 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24706 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24707 });}
24708 var avgExpandBtn=document.getElementById('r-avglines-expand');
24709 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24710 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24711 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24712 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24713 });}
24714 var subExpandBtn=document.getElementById('r-submodule-expand');
24715 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24716 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24717 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24718 var metCtrl=
24719 '<select class="r-chart-select" id="r-sub-modal-metric">'
24720 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24721 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24722 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24723 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24724 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24725 +'</select>';
24726 var sortCtrl=
24727 '<select class="r-chart-select" id="r-sub-modal-sort">'
24728 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24729 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24730 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24731 +'</select>';
24732 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24733 if(wrap){
24734 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24735 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24736 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24737 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24738 if(mSub)mSub.addEventListener('change',reRenderSub);
24739 if(mSort)mSort.addEventListener('change',reRenderSub);
24740 }
24741 });}
24742 })();
24743
24744 // ── Comment Density: comments / (code + comments) per language ───────────
24745 function renderDensityInEl(el,shOvr){
24746 if(!el||!LANG_D||!LANG_D.length)return;
24747 var n=LANG_D.length||1;
24748 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24749 var svgW=Math.max(320,el.offsetWidth||480);
24750 var BW=Math.max(120,svgW-LW-80);
24751 var topPad=4,botPad=26;
24752 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24753 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24754 var densities=LANG_D.map(function(d){
24755 var sig=(d.code||0)+(d.comments||0);
24756 return sig>0?(d.comments||0)/sig:0;
24757 });
24758 var maxDen=Math.max.apply(null,densities)||1;
24759 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">';
24760 LANG_D.forEach(function(d,i){
24761 var den=densities[i],bw=den/maxDen*BW;
24762 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24763 var pct=Math.round(den*100);
24764 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>';
24765 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"/>';
24766 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24767 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>';
24768 });
24769 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>';
24770 s+='</svg>';
24771 el.innerHTML=s;
24772 }
24773 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24774 renderDensity();
24775
24776 // ── Avg Lines per File: code / files per language ─────────────────────
24777 function renderAvgLinesInEl(el,shOvr){
24778 if(!el||!LANG_D||!LANG_D.length)return;
24779 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24780 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24781 var n=data.length||1;
24782 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24783 var svgW=Math.max(320,el.offsetWidth||480);
24784 var BW=Math.max(120,svgW-LW-80);
24785 var topPad=4,botPad=26;
24786 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24787 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24788 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24789 var maxAvg=Math.max.apply(null,avgs)||1;
24790 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">';
24791 data.forEach(function(d,i){
24792 var avg=avgs[i],bw=avg/maxAvg*BW;
24793 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24794 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>';
24795 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"/>';
24796 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24797 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>';
24798 });
24799 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>';
24800 s+='</svg>';
24801 el.innerHTML=s;
24802 }
24803 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24804 renderAvgLines();
24805
24806 // ── Repository Overview: overall row + per-submodule rows ────────────
24807 function renderSubmoduleInEl(el,key,sort,shOvr){
24808 if(!el)return;
24809 var overall={
24810 name:'Overall',
24811 code:{{ code_lines }},
24812 comment:{{ comment_lines }},
24813 blank:{{ blank_lines }},
24814 physical:{{ physical_lines }},
24815 files:{{ files_analyzed }},
24816 isOverall:true
24817 };
24818 var subs=SUB_D.slice();
24819 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24820 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24821 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24822 var data=[overall].concat(subs);
24823 var sepH=subs.length>0?14:0;
24824 var naturalH=data.length*32+sepH+16;
24825 var SH=shOvr||Math.max(100,naturalH);
24826 var svgW=Math.max(320,el.offsetWidth||480);
24827 var LW=116,BW=Math.max(200,svgW-LW-54);
24828 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24829 var OVERALL_COL='#6b7280';
24830 var topPad=4,botPad=8;
24831 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24832 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24833 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">';
24834 var yOff=topPad;
24835 data.forEach(function(d,i){
24836 var v=d[key]||0,bw=v/maxV*BW;
24837 var y=yOff+Math.floor((rowSlot-bH)/2);
24838 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24839 var label=d.name||d.path||'?';
24840 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>';
24841 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24842 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24843 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>';
24844 yOff+=rowSlot;
24845 if(d.isOverall&&subs.length>0){
24846 yOff+=sepH;
24847 }
24848 });
24849 s+='</svg>';
24850 el.innerHTML=s;
24851 }
24852 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24853 var subSel=document.getElementById('r-sub-metric');
24854 var sortSel=document.getElementById('r-sub-sort');
24855 renderSubmodule('code','desc');
24856 if(subSel){
24857 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24858 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24859 }
24860
24861 // Equalise heights within each chart row: if one chart in a grid row is taller
24862 // than its neighbour, re-render the shorter one at the taller height so bars fill
24863 // the available vertical space instead of leaving a gap.
24864 function syncRowHeights(){
24865 var avgEl=document.getElementById('r-avglines-chart');
24866 var subEl=document.getElementById('r-submodule-chart');
24867 if(avgEl&&subEl){
24868 var avgSvg=avgEl.querySelector('svg');
24869 var subSvg=subEl.querySelector('svg');
24870 if(avgSvg&&subSvg){
24871 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24872 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24873 var key=subSel?subSel.value||'code':'code';
24874 var sort=sortSel?sortSel.value:'desc';
24875 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24876 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24877 }
24878 }
24879 var semEl=document.getElementById('r-semantic-chart');
24880 var denEl=document.getElementById('r-density-chart');
24881 if(semEl&&denEl){
24882 var semSvg=semEl.querySelector('svg');
24883 var denSvg=denEl.querySelector('svg');
24884 if(semSvg&&denSvg){
24885 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24886 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24887 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24888 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24889 }
24890 }
24891 }
24892 syncRowHeights();
24893
24894 // Re-render all SVG charts when the window is resized so bars fill the card.
24895 var _rResizeTimer;
24896 window.addEventListener('resize',function(){
24897 clearTimeout(_rResizeTimer);
24898 _rResizeTimer=setTimeout(function(){
24899 var rcompBtn=document.querySelector('[data-rcomp].active');
24900 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24901 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24902 if(semSel)renderSemantic(semSel.value||'functions');
24903 renderDensity();
24904 renderAvgLines();
24905 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24906 syncRowHeights();
24907 },120);
24908 });
24909 })();
24910
24911 (function randomizeWatermarks() {
24912 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24913 if (!wms.length) return;
24914 var placed = [];
24915 function tooClose(top, left) {
24916 for (var i = 0; i < placed.length; i++) {
24917 var dt = Math.abs(placed[i][0] - top);
24918 var dl = Math.abs(placed[i][1] - left);
24919 if (dt < 20 && dl < 18) return true;
24920 }
24921 return false;
24922 }
24923 function pick(leftBand) {
24924 for (var attempt = 0; attempt < 50; attempt++) {
24925 var top = Math.random() * 85 + 5;
24926 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24927 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24928 }
24929 var top = Math.random() * 85 + 5;
24930 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24931 placed.push([top, left]);
24932 return [top, left];
24933 }
24934 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24935 var half = Math.floor(wms.length / 2);
24936 wms.forEach(function (img, i) {
24937 var pos = pick(i < half);
24938 var size = Math.floor(Math.random() * 100 + 160);
24939 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24940 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24941 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;
24942 });
24943 })();
24944
24945 (function spawnCodeParticles() {
24946 var container = document.getElementById('code-particles');
24947 if (!container) return;
24948 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'];
24949 for (var i = 0; i < 38; i++) {
24950 (function(idx) {
24951 var el = document.createElement('span');
24952 el.className = 'code-particle';
24953 el.textContent = snippets[idx % snippets.length];
24954 var left = Math.random() * 94 + 2;
24955 var top = Math.random() * 88 + 6;
24956 var dur = (Math.random() * 10 + 9).toFixed(1);
24957 var delay = (Math.random() * 18).toFixed(1);
24958 var rot = (Math.random() * 26 - 13).toFixed(1);
24959 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24960 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';
24961 container.appendChild(el);
24962 })(i);
24963 }
24964 })();
24965
24966 {% if pdf_generating %}
24967 // Poll for PDF readiness and swap the disabled button to a live link once done.
24968 (function() {
24969 var openBtn = document.getElementById('pdf-open-btn');
24970 var dlBtn = document.getElementById('pdf-download-btn');
24971 function checkPdf() {
24972 fetch('/api/runs/{{ run_id }}/pdf-status')
24973 .then(function(r) { return r.json(); })
24974 .then(function(d) {
24975 if (d.ready) {
24976 if (openBtn) {
24977 var a = document.createElement('a');
24978 a.className = 'button';
24979 a.id = 'pdf-open-btn';
24980 a.href = '/runs/pdf/{{ run_id }}';
24981 a.target = '_blank';
24982 a.rel = 'noopener';
24983 a.textContent = 'Open PDF';
24984 openBtn.replaceWith(a);
24985 }
24986 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24987 } else {
24988 setTimeout(checkPdf, 3000);
24989 }
24990 })
24991 .catch(function() { setTimeout(checkPdf, 5000); });
24992 }
24993 setTimeout(checkPdf, 3000);
24994 })();
24995 {% endif %}
24996
24997 })();
24998 </script>
24999 <script nonce="{{ csp_nonce }}">
25000 (function(){
25001 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'}];
25002 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);});}
25003 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25004 function init(){
25005 var btn=document.getElementById('settings-btn');if(!btn)return;
25006 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25007 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>';
25008 document.body.appendChild(m);
25009 var g=document.getElementById('scheme-grid');
25010 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);});
25011 var cl=document.getElementById('settings-close');
25012 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);});})();
25013 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');});
25014 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25015 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25016 }
25017 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25018 }());
25019 </script>
25020 <footer class="site-footer">
25021 local code analysis - metrics, history and reports
25022 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25023 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25024 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25025 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25026 · <a href="/api-docs" rel="noopener">REST API</a>
25027 </footer>
25028 {% if confluence_configured %}
25029 <script nonce="{{ csp_nonce }}">
25030 (function() {
25031 var postBtn = document.getElementById('postConfluenceBtn');
25032 var copyBtn = document.getElementById('copyWikiBtn');
25033 var modal = document.getElementById('confluenceModal');
25034 if (!postBtn || !modal) return;
25035
25036 postBtn.addEventListener('click', function() {
25037 document.getElementById('confStatus').style.display = 'none';
25038 modal.style.display = 'flex';
25039 });
25040 document.getElementById('confCancelBtn').addEventListener('click', function() {
25041 modal.style.display = 'none';
25042 });
25043 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25044
25045 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25046 var btn = this;
25047 btn.disabled = true;
25048 var status = document.getElementById('confStatus');
25049 status.style.display = 'block';
25050 status.style.background = '#dbeafe';
25051 status.style.color = '#1e40af';
25052 status.textContent = 'Posting to Confluence\u2026';
25053 var resp = await fetch('/api/confluence/post', {
25054 method: 'POST',
25055 headers: { 'Content-Type': 'application/json' },
25056 body: JSON.stringify({
25057 run_id: '{{ run_id }}',
25058 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25059 report_url: document.getElementById('confReportUrl').value.trim() || null
25060 })
25061 });
25062 var data = await resp.json();
25063 if (data.ok) {
25064 status.style.background = '#dcfce7'; status.style.color = '#166534';
25065 status.textContent = 'Posted! Page ID: ' + data.page_id;
25066 } else {
25067 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25068 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25069 }
25070 btn.disabled = false;
25071 });
25072
25073 if (copyBtn) {
25074 copyBtn.addEventListener('click', async function() {
25075 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25076 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25077 var text = await resp.text();
25078 try {
25079 await navigator.clipboard.writeText(text);
25080 var orig = copyBtn.textContent;
25081 copyBtn.textContent = 'Copied!';
25082 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25083 } catch(e) {
25084 alert('Clipboard write failed \u2014 check browser permissions.');
25085 }
25086 });
25087 }
25088 })();
25089 </script>
25090 {% endif %}
25091 <script nonce="{{ csp_nonce }}">
25092 (function() {
25093 var deleteBtn = document.getElementById('delete-run-btn');
25094 var modal = document.getElementById('delete-run-modal');
25095 var cancelBtn = document.getElementById('delete-run-cancel');
25096 var confirmBtn= document.getElementById('delete-run-confirm');
25097 if (!deleteBtn || !modal) return;
25098 deleteBtn.addEventListener('click', function() {
25099 document.getElementById('delete-run-status').style.display = 'none';
25100 modal.style.display = 'flex';
25101 });
25102 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25103 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25104 confirmBtn.addEventListener('click', async function() {
25105 confirmBtn.disabled = true;
25106 cancelBtn.disabled = true;
25107 var status = document.getElementById('delete-run-status');
25108 status.style.display = 'block';
25109 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25110 status.textContent = 'Deleting\u2026';
25111 try {
25112 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25113 if (resp.status === 204 || resp.ok) {
25114 status.style.background = '#dcfce7'; status.style.color = '#166534';
25115 status.textContent = 'Deleted. Redirecting\u2026';
25116 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25117 } else {
25118 var d = await resp.json().catch(function(){return {};});
25119 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25120 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25121 confirmBtn.disabled = false;
25122 cancelBtn.disabled = false;
25123 }
25124 } catch (e) {
25125 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25126 status.textContent = 'Network error: ' + String(e);
25127 confirmBtn.disabled = false;
25128 cancelBtn.disabled = false;
25129 }
25130 });
25131 })();
25132 </script>
25133 <script nonce="{{ csp_nonce }}">(function(){
25134 var bundleBtn = document.getElementById('download-bundle-btn');
25135 if (bundleBtn) {
25136 bundleBtn.addEventListener('click', function() {
25137 bundleBtn.disabled = true;
25138 var orig = bundleBtn.textContent;
25139 bundleBtn.textContent = 'Preparing\u2026';
25140 fetch('/api/runs/{{ run_id }}/bundle')
25141 .then(function(r) {
25142 if (!r.ok) throw new Error('HTTP ' + r.status);
25143 return r.blob();
25144 })
25145 .then(function(blob) {
25146 var url = URL.createObjectURL(blob);
25147 var a = document.createElement('a');
25148 a.href = url;
25149 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25150 document.body.appendChild(a);
25151 a.click();
25152 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25153 bundleBtn.disabled = false;
25154 bundleBtn.textContent = orig;
25155 })
25156 .catch(function(e) {
25157 bundleBtn.disabled = false;
25158 bundleBtn.textContent = orig;
25159 alert('Bundle download failed: ' + String(e));
25160 });
25161 });
25162 }
25163 })();</script>
25164 <script nonce="{{ csp_nonce }}">(function(){
25165 var dot=document.getElementById('status-dot');
25166 var pingEl=document.getElementById('server-ping-ms');
25167 var tipEl=document.getElementById('server-tip-ping');
25168 var fm=document.getElementById('footer-mode');
25169 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)';}}
25170 function doPing(){
25171 var t0=performance.now();
25172 fetch('/healthz',{cache:'no-store'})
25173 .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);})
25174 .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)';}});
25175 }
25176 doPing();
25177 setInterval(doPing,5000);
25178 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');}
25179 })();</script>
25180 <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>
25181 {% if let Some(banner) = report_header_footer %}
25182 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25183 {% endif %}
25184</body>
25185</html>
25186"##,
25187 ext = "html"
25188)]
25189#[allow(clippy::struct_excessive_bools)]
25191struct ResultTemplate {
25192 version: &'static str,
25193 report_title: String,
25194 project_path: String,
25195 output_dir: String,
25196 run_id: String,
25197 files_analyzed: u64,
25198 files_skipped: u64,
25199 physical_lines: u64,
25200 code_lines: u64,
25201 comment_lines: u64,
25202 blank_lines: u64,
25203 mixed_lines: u64,
25204 functions: u64,
25205 classes: u64,
25206 variables: u64,
25207 imports: u64,
25208 html_url: Option<String>,
25209 pdf_url: Option<String>,
25210 json_url: Option<String>,
25211 html_download_url: Option<String>,
25212 pdf_download_url: Option<String>,
25213 json_download_url: Option<String>,
25214 html_path: Option<String>,
25215 json_path: Option<String>,
25216 prev_run_id: Option<String>,
25217 prev_run_timestamp: Option<String>,
25218 prev_run_code_lines: Option<u64>,
25219 prev_fa_str: String,
25221 prev_fs_str: String,
25222 prev_pl_str: String,
25223 prev_cl_str: String,
25224 prev_cml_str: String,
25225 prev_bl_str: String,
25226 delta_fa_str: String,
25228 delta_fa_class: String,
25229 delta_fs_str: String,
25230 delta_fs_class: String,
25231 delta_pl_str: String,
25232 delta_pl_class: String,
25233 delta_cl_str: String,
25234 delta_cl_class: String,
25235 delta_cml_str: String,
25236 delta_cml_class: String,
25237 delta_bl_str: String,
25238 delta_bl_class: String,
25239 delta_lines_added: Option<i64>,
25241 delta_lines_removed: Option<i64>,
25242 delta_lines_net_str: String,
25243 delta_lines_net_class: String,
25244 delta_files_added: Option<usize>,
25245 delta_files_removed: Option<usize>,
25246 delta_files_modified: Option<usize>,
25247 delta_files_unchanged: Option<usize>,
25248 delta_unmodified_lines: Option<u64>,
25249 git_branch: Option<String>,
25251 git_branch_url: Option<String>,
25252 git_commit: Option<String>,
25253 git_commit_long: Option<String>,
25254 git_author: Option<String>,
25255 git_commit_url: Option<String>,
25256 scan_performed_by: String,
25258 scan_time_display: String,
25259 scan_time_utc_ms: i64,
25260 os_display: String,
25261 test_count: u64,
25262 test_assertion_count: u64,
25264 prev_scan_count: usize,
25266 current_scan_number: usize,
25267 submodule_rows: Vec<SubmoduleRow>,
25269 scan_config_url: String,
25270 lang_chart_json: String,
25271 #[allow(dead_code)]
25273 scatter_chart_json: String,
25274 #[allow(dead_code)]
25275 semantic_chart_json: String,
25276 #[allow(dead_code)]
25277 submodule_chart_json: String,
25278 #[allow(dead_code)]
25279 has_submodule_data: bool,
25280 #[allow(dead_code)]
25281 has_semantic_data: bool,
25282 pdf_generating: bool,
25283 csp_nonce: String,
25284 confluence_configured: bool,
25286 server_mode: bool,
25287 report_header_footer: Option<String>,
25289 run_id_short: String,
25290 #[allow(dead_code)]
25292 is_offline: bool,
25293 cyclomatic_complexity: u64,
25295 lsloc: Option<u64>,
25297 uloc: u64,
25299 dryness_pct_str: String,
25301 duplicate_group_count: usize,
25303 has_cocomo: bool,
25305 cocomo_effort_str: String,
25307 cocomo_duration_str: String,
25309 cocomo_staff_str: String,
25311 cocomo_ksloc_str: String,
25313 cocomo_mode_label: String,
25315 cocomo_mode_tooltip: String,
25317 complexity_alert: u32,
25319 has_coverage_data: bool,
25321 cov_line_pct: String,
25323 cov_fn_pct: String,
25325 cov_branch_pct: String,
25327 cov_lines_summary: String,
25329}
25330
25331#[derive(Template)]
25332#[template(
25333 source = r##"
25334<!doctype html>
25335<html lang="en">
25336<head>
25337 <meta charset="utf-8">
25338 <meta name="viewport" content="width=device-width, initial-scale=1">
25339 <title>OxideSLOC | Analyzing…</title>
25340 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25341 <style nonce="{{ csp_nonce }}">
25342 :root {
25343 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25344 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25345 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25346 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25347 }
25348 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25349 *{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;}
25350 .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);}
25351 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25352 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25353 .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));}
25354 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25355 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25356 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25357 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25358 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25359 @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; } }
25360 .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;}
25361 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25362 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25363 .page-body{padding:32px 24px 36px;}
25364 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25365 .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;}
25366 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25367 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25368 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25369 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25370 .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;}
25371 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25372 .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;}
25373 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25374 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25375 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25376 .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;}
25377 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25378 .hidden{display:none!important;}
25379 .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;}
25380 .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;}
25381 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25382 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25383 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25384 .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);}
25385 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25386 .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;}
25387 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25388 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25389 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25390 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25391 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25392 .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;}
25393 @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));}}
25394 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25395 .site-footer a{color:var(--muted);}
25396 .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;}
25397 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25398 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25399 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25400 </style>
25401</head>
25402<body>
25403 <div class="background-watermarks" aria-hidden="true">
25404 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25405 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25406 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25407 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25408 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25409 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25410 </div>
25411 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25412 <nav class="top-nav">
25413 <div class="top-nav-inner">
25414 <a href="/" class="brand">
25415 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25416 <div class="brand-copy">
25417 <h1 class="brand-title">OxideSLOC</h1>
25418 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25419 </div>
25420 </a>
25421 <div class="nav-right">
25422 <a class="nav-pill" href="/">Home</a>
25423 <div class="nav-dropdown">
25424 <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>
25425 <div class="nav-dropdown-menu">
25426 <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>
25427 </div>
25428 </div>
25429 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25430 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25431 <div class="nav-dropdown">
25432 <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>
25433 <div class="nav-dropdown-menu">
25434 <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>
25435 </div>
25436 </div>
25437 <div class="server-status-wrap" id="server-status-wrap">
25438 <div class="nav-pill server-online-pill" id="server-status-pill">
25439 <span class="status-dot" id="status-dot"></span>
25440 <span id="server-status-label">Server</span>
25441 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25442 </div>
25443 <div class="server-status-tip">
25444 OxideSLOC is running — accessible on your network.
25445 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25446 </div>
25447 </div>
25448 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25449 <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>
25450 </button>
25451 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25452 <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>
25453 <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>
25454 </button>
25455 </div>
25456 </div>
25457 </nav>
25458 <div class="page-body">
25459 <div class="wait-panel">
25460 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25461 <h2 class="wait-title">Analyzing your project…</h2>
25462 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25463 <div class="path-block">{{ project_path }}</div>
25464 <div class="metrics-row">
25465 <div class="metric-card">
25466 <div class="metric-label">Elapsed</div>
25467 <div class="metric-value" id="elapsed">0s</div>
25468 </div>
25469 <div class="metric-card">
25470 <div class="metric-label">Phase</div>
25471 <div class="metric-value" id="phase">Starting</div>
25472 </div>
25473 <div class="metric-card hidden" id="files-card">
25474 <div class="metric-label">Files</div>
25475 <div class="metric-value" id="files-progress">0</div>
25476 </div>
25477 </div>
25478 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25479 <div class="warn-slow hidden" id="warn-slow">
25480 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.
25481 </div>
25482 <div class="err-panel hidden" id="err-panel">
25483 <strong>Analysis failed</strong>
25484 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25485 </div>
25486 <div class="actions hidden" id="actions">
25487 <a href="/scan" class="btn-primary">Try Again</a>
25488 <a href="/view-reports" class="btn-outline">View Reports</a>
25489 </div>
25490 </div>
25491 </div>
25492 <script nonce="{{ csp_nonce }}">
25493 (function() {
25494 var WAIT_ID = {{ wait_id_json|safe }};
25495 var startTime = Date.now();
25496 var pollInterval = 1500;
25497 var retries = 0;
25498 var maxRetries = 5;
25499 var warnShown = false;
25500
25501 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();}
25502
25503 function elapsed() {
25504 return Math.floor((Date.now() - startTime) / 1000);
25505 }
25506
25507 function updateElapsed() {
25508 var s = elapsed();
25509 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25510 }
25511
25512 function setPhase(txt) {
25513 document.getElementById('phase').textContent = txt;
25514 }
25515
25516 var elapsedTimer = setInterval(updateElapsed, 1000);
25517
25518 function poll() {
25519 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25520 .then(function(r) {
25521 if (!r.ok) throw new Error('HTTP ' + r.status);
25522 return r.json();
25523 })
25524 .then(function(data) {
25525 retries = 0;
25526 if (data.state === 'complete') {
25527 clearInterval(elapsedTimer);
25528 setPhase('Done');
25529 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25530 } else if (data.state === 'failed') {
25531 clearInterval(elapsedTimer);
25532 setPhase('Failed');
25533 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25534 document.getElementById('err-panel').classList.remove('hidden');
25535 document.getElementById('actions').classList.remove('hidden');
25536 } else {
25537 // still running
25538 var s = elapsed();
25539 if (s > 90 && !warnShown) {
25540 warnShown = true;
25541 document.getElementById('warn-slow').classList.remove('hidden');
25542 }
25543 setPhase(data.phase || 'Running');
25544 var fd = data.files_done || 0, ft = data.files_total || 0;
25545 if (ft > 0) {
25546 var card = document.getElementById('files-card');
25547 if (card) card.classList.remove('hidden');
25548 var fp = document.getElementById('files-progress');
25549 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25550 }
25551 setTimeout(poll, pollInterval);
25552 }
25553 })
25554 .catch(function(err) {
25555 retries++;
25556 if (retries >= maxRetries) {
25557 clearInterval(elapsedTimer);
25558 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25559 document.getElementById('err-panel').classList.remove('hidden');
25560 document.getElementById('actions').classList.remove('hidden');
25561 } else {
25562 // exponential back-off capped at 8s
25563 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25564 }
25565 });
25566 }
25567
25568 setTimeout(poll, pollInterval);
25569
25570 // If the browser restores this page from bfcache (Back after viewing results),
25571 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25572 window.addEventListener("pageshow", function(e) {
25573 if (e.persisted) { setTimeout(poll, 200); }
25574 });
25575 })();
25576 </script>
25577 <footer class="site-footer">
25578 local code analysis - metrics, history and reports
25579 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25580 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25581 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25582 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25583 · <a href="/api-docs" rel="noopener">REST API</a>
25584 </footer>
25585 <script nonce="{{ csp_nonce }}">
25586 (function(){
25587 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25588 if(s==="dark")b.classList.add("dark-theme");
25589 var tt=document.getElementById("theme-toggle");
25590 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25591 })();
25592 (function spawnCodeParticles(){
25593 var c=document.getElementById('code-particles');if(!c)return;
25594 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'];
25595 for(var i=0;i<32;i++){(function(idx){
25596 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25597 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25598 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25599 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25600 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25601 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25602 c.appendChild(el);
25603 })(i);}
25604 })();
25605 (function randomizeWatermarks(){
25606 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25607 var placed=[];
25608 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;}
25609 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];}
25610 var half=Math.floor(wms.length/2);
25611 wms.forEach(function(img,i){
25612 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25613 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25614 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25615 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25616 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25617 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25618 });
25619 })();
25620 </script>
25621 <script nonce="{{ csp_nonce }}">
25622 (function(){
25623 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'}];
25624 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);});}
25625 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25626 function init(){
25627 var btn=document.getElementById('settings-btn');if(!btn)return;
25628 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25629 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>';
25630 document.body.appendChild(m);
25631 var g=document.getElementById('scheme-grid');
25632 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);});
25633 var cl=document.getElementById('settings-close');
25634 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);});})();
25635 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');});
25636 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25637 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25638 }
25639 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25640 }());
25641 </script>
25642 <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]';
25643 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;}
25644 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>
25645</body>
25646</html>
25647"##,
25648 ext = "html"
25649)]
25650struct ScanWaitTemplate {
25651 version: &'static str,
25652 wait_id_json: String,
25653 project_path: String,
25654 csp_nonce: String,
25655}
25656
25657#[derive(Template)]
25658#[template(
25659 source = r##"
25660<!doctype html>
25661<html lang="en">
25662<head>
25663 <meta charset="utf-8">
25664 <meta name="viewport" content="width=device-width, initial-scale=1">
25665 <title>OxideSLOC | Error</title>
25666 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25667 <style nonce="{{ csp_nonce }}">
25668 :root {
25669 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25670 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25671 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25672 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25673 }
25674 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25675 *{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;}
25676 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25677 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25678 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25679 .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);}
25680 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25681 .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));}
25682 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25683 .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;}
25684 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25685 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25686 @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; } }
25687 .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;}
25688 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25689 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25690 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25691 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25692 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25693 .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;}
25694 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25695 .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);}
25696 .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;}
25697 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25698 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25699 .settings-modal-body{padding:14px 16px 16px;}
25700 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25701 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25702 .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;}
25703 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25704 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25705 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25706 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25707 .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;}
25708 .tz-select:focus{border-color:var(--oxide);}
25709 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25710 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25711 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25712 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25713 .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;}
25714 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25715 .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);}
25716 .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;}
25717 .btn-secondary:hover{background:var(--line);}
25718 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25719 .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;}
25720 .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;}
25721 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25722 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25723 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25724 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25725 .bug-report-panel.open{display:flex;}
25726 .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;}
25727 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25728 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25729 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25730 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25731 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25732 .br-network-badge.online .br-net-dot{background:#2a6846;}
25733 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25734 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25735 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25736 .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;}
25737 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25738 .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;}
25739 .btn-sm:hover{background:var(--line);}
25740 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25741 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25742 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25743 .bug-report-hint a:hover{text-decoration:underline;}
25744 .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;}
25745 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25746 .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;}
25747 .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;}
25748 .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;}
25749 @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));}}
25750 .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;}
25751 </style>
25752</head>
25753<body>
25754 <div class="background-watermarks" aria-hidden="true">
25755 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25756 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25757 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25758 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25759 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25760 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25761 </div>
25762 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25763 <div class="top-nav">
25764 <div class="top-nav-inner">
25765 <a class="brand" href="/">
25766 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25767 <div class="brand-copy">
25768 <div class="brand-title">OxideSLOC</div>
25769 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25770 </div>
25771 </a>
25772 <div class="nav-right">
25773 <a class="nav-pill" href="/">Home</a>
25774 <div class="nav-dropdown">
25775 <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>
25776 <div class="nav-dropdown-menu">
25777 <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>
25778 </div>
25779 </div>
25780 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25781 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25782 <div class="nav-dropdown">
25783 <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>
25784 <div class="nav-dropdown-menu">
25785 <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>
25786 </div>
25787 </div>
25788 <div class="server-status-wrap" id="server-status-wrap">
25789 <div class="nav-pill server-online-pill" id="server-status-pill">
25790 <span class="status-dot" id="status-dot"></span>
25791 <span id="server-status-label">Server</span>
25792 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25793 </div>
25794 <div class="server-status-tip">
25795 OxideSLOC is running — accessible on your network.
25796 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25797 </div>
25798 </div>
25799 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25800 <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>
25801 </button>
25802 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25803 <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>
25804 <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>
25805 </button>
25806 </div>
25807 </div>
25808 </div>
25809
25810 <div class="page">
25811 <div class="panel">
25812 <h1>Error</h1>
25813 <div class="error-box" id="error-msg-text">{{ message }}</div>
25814 <div id="br-meta" hidden
25815 data-version="{{ version }}"
25816 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25817 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25818 <div class="actions">
25819 <a class="btn-primary" href="/scan">Back to setup</a>
25820 {% if let Some(report_url) = last_report_url %}
25821 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25822 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25823 {% else %}
25824 <a class="btn-secondary" href="/view-reports">View Reports</a>
25825 {% endif %}
25826 </div>
25827 <div class="bug-report-section" id="bug-report-section">
25828 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25829 <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>
25830 Generate Bug Report
25831 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25832 </button>
25833 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25834 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
25835 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
25836 <div class="bug-report-btns">
25837 <button type="button" class="btn-sm" id="bug-report-copy">
25838 <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>
25839 Copy to clipboard
25840 </button>
25841 <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;">
25842 <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>
25843 Open GitHub Issue
25844 </a>
25845 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25846 <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>
25847 Save as file
25848 </button>
25849 </div>
25850 <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>
25851 <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>
25852 </div>
25853 </div>
25854 </div>
25855 </div>
25856 <footer class="site-footer">
25857 oxide-sloc v{{ version }} — local code metrics workbench ·
25858 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25859 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25860 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25861 · <a href="/api-docs" rel="noopener">REST API</a>
25862 </footer>
25863 <script nonce="{{ csp_nonce }}">(function(){
25864 var meta=document.getElementById('br-meta');
25865 var pre=document.getElementById('bug-report-pre');
25866 var copyBtn=document.getElementById('bug-report-copy');
25867 var trigger=document.getElementById('bug-report-trigger');
25868 var panel=document.getElementById('bug-report-panel');
25869 var networkBadge=document.getElementById('br-network-badge');
25870 var networkLabel=document.getElementById('br-network-label');
25871 var ghLink=document.getElementById('bug-report-github-link');
25872 var saveBtn=document.getElementById('bug-report-save');
25873 var hintOnline=document.getElementById('br-hint-online');
25874 var hintOffline=document.getElementById('br-hint-offline');
25875 if(!meta||!pre)return;
25876 var ver=meta.getAttribute('data-version')||'';
25877 var runId=meta.getAttribute('data-run-id')||'';
25878 var code=meta.getAttribute('data-error-code')||'';
25879 var msgEl=document.getElementById('error-msg-text');
25880 var msg=msgEl?msgEl.textContent.trim():'';
25881 function getBrowser(){
25882 var ua=navigator.userAgent;
25883 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25884 if(!m)return 'Unknown browser';
25885 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25886 return n+' '+m[2];
25887 }
25888 var lines=['oxide-sloc Bug Report','==============================',''];
25889 lines.push('App version: v'+ver);
25890 if(code)lines.push('HTTP status: '+code);
25891 if(runId)lines.push('Run ID: '+runId);
25892 lines.push('Page: '+window.location.pathname+(window.location.search||''));
25893 lines.push('Timestamp: '+new Date().toISOString());
25894 lines.push('Browser: '+getBrowser());
25895 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
25896 lines.push('');
25897 lines.push('Error message:');
25898 lines.push(msg);
25899 lines.push('');
25900 lines.push('Steps to reproduce:');
25901 lines.push(' 1. ');
25902 lines.push('');
25903 lines.push('Expected behavior:');
25904 lines.push(' ');
25905 pre.textContent=lines.join('\n');
25906 function applyNetwork(online){
25907 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25908 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25909 if(ghLink){
25910 if(online){
25911 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25912 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25913 }
25914 ghLink.style.display=online?'inline-flex':'none';
25915 }
25916 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25917 if(hintOnline)hintOnline.style.display=online?'block':'none';
25918 if(hintOffline)hintOffline.style.display=online?'none':'block';
25919 }
25920 applyNetwork(navigator.onLine);
25921 var probed=false;
25922 function probeNetwork(){
25923 if(probed)return;probed=true;
25924 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25925 var probeIdx=0;
25926 function tryNext(){
25927 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25928 var u=probeUrls[probeIdx++];
25929 var c2=new AbortController();
25930 var t2=setTimeout(function(){c2.abort();},4000);
25931 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25932 .then(function(){clearTimeout(t2);applyNetwork(true);})
25933 .catch(function(){clearTimeout(t2);tryNext();});
25934 }
25935 tryNext();
25936 }
25937 if(trigger&&panel){
25938 trigger.addEventListener('click',function(){
25939 var open=panel.classList.toggle('open');
25940 trigger.classList.toggle('open',open);
25941 trigger.setAttribute('aria-expanded',open?'true':'false');
25942 if(open)probeNetwork();
25943 });
25944 }
25945 if(copyBtn){
25946 copyBtn.addEventListener('click',function(){
25947 var txt=pre.textContent;
25948 if(navigator.clipboard&&navigator.clipboard.writeText){
25949 navigator.clipboard.writeText(txt).then(function(){
25950 copyBtn.textContent='\u2713 Copied!';
25951 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);
25952 });
25953 }else{
25954 var ta=document.createElement('textarea');
25955 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25956 document.body.appendChild(ta);ta.select();
25957 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25958 document.body.removeChild(ta);
25959 }
25960 });
25961 }
25962 if(saveBtn){
25963 saveBtn.addEventListener('click',function(){
25964 var txt=pre.textContent;
25965 var blob=new Blob([txt],{type:'text/plain'});
25966 var url=URL.createObjectURL(blob);
25967 var a=document.createElement('a');
25968 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25969 document.body.appendChild(a);a.click();
25970 document.body.removeChild(a);URL.revokeObjectURL(url);
25971 });
25972 }
25973 })();</script>
25974 <script nonce="{{ csp_nonce }}">
25975 (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");});})();
25976 (function spawnCodeParticles() {
25977 var container = document.getElementById('code-particles');
25978 if (!container) return;
25979 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'];
25980 for (var i = 0; i < 38; i++) {
25981 (function(idx) {
25982 var el = document.createElement('span');
25983 el.className = 'code-particle';
25984 el.textContent = snippets[idx % snippets.length];
25985 var left = Math.random() * 94 + 2;
25986 var top = Math.random() * 88 + 6;
25987 var dur = (Math.random() * 10 + 9).toFixed(1);
25988 var delay = (Math.random() * 18).toFixed(1);
25989 var rot = (Math.random() * 26 - 13).toFixed(1);
25990 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25991 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';
25992 container.appendChild(el);
25993 })(i);
25994 }
25995 })();
25996 (function randomizeWatermarks() {
25997 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25998 var placed = [];
25999 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; }
26000 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]; }
26001 var half = Math.floor(wms.length/2);
26002 wms.forEach(function(img, i) {
26003 var pos = pick(i < half);
26004 var w = Math.floor(Math.random()*60+80);
26005 var rot = (Math.random()*40-20).toFixed(1);
26006 var op = (Math.random()*0.08+0.05).toFixed(2);
26007 var animDur = (Math.random()*6+5).toFixed(1);
26008 var animDelay = (Math.random()*10).toFixed(1);
26009 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';
26010 });
26011 })();
26012 </script>
26013 <script nonce="{{ csp_nonce }}">
26014 (function(){
26015 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'}];
26016 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);});}
26017 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26018 function init(){
26019 var btn=document.getElementById('settings-btn');if(!btn)return;
26020 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26021 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>';
26022 document.body.appendChild(m);
26023 var g=document.getElementById('scheme-grid');
26024 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);});
26025 var cl=document.getElementById('settings-close');
26026 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);});})();
26027 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');});
26028 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26029 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26030 }
26031 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26032 }());
26033 </script>
26034 <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]';
26035 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;}
26036 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>
26037</body>
26038</html>
26039"##,
26040 ext = "html"
26041)]
26042struct ErrorTemplate {
26043 message: String,
26044 last_report_url: Option<String>,
26046 last_report_label: Option<String>,
26048 run_id: Option<String>,
26050 error_code: Option<u16>,
26052 csp_nonce: String,
26053 version: &'static str,
26054}
26055
26056#[derive(Template)]
26059#[template(
26060 source = r##"
26061<!doctype html>
26062<html lang="en">
26063<head>
26064 <meta charset="utf-8">
26065 <meta name="viewport" content="width=device-width, initial-scale=1">
26066 <title>OxideSLOC | Locate Report</title>
26067 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26068 <style nonce="{{ csp_nonce }}">
26069 :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);}
26070 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26071 *{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;}
26072 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26073 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26074 .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);}
26075 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26076 .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));}
26077 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26078 .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;}
26079 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26080 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26081 @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;}}
26082 .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;}
26083 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26084 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26085 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26086 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26087 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26088 .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;}
26089 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26090 .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);}
26091 .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;}
26092 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26093 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26094 .settings-modal-body{padding:14px 16px 16px;}
26095 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26096 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26097 .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;}
26098 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26099 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26100 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26101 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26102 .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;}
26103 .tz-select:focus{border-color:var(--oxide);}
26104 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26105 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26106 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26107 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26108 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26109 .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;}
26110 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26111 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26112 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26113 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26114 .locate-row{display:flex;gap:8px;align-items:stretch;}
26115 .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;}
26116 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26117 body.dark-theme .locate-input{background:var(--surface-2);}
26118 .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;}
26119 .warning-banner.show{display:flex;}
26120 .warning-banner svg{flex:0 0 auto;}
26121 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26122 .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;}
26123 .error-inline.show{display:flex;}
26124 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26125 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26126 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26127 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26128 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26129 .err-kv-p{margin:0 0 4px;}
26130 .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;}
26131 .success-inline.show{display:flex;}
26132 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26133 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26134 .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;}
26135 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26136 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26137 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26138 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26139 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26140 .fh-row:last-child{border-bottom:none;}
26141 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26142 .fh-dir{font-weight:800;color:var(--text);}
26143 .fh-hl{color:var(--oxide);font-weight:700;}
26144 .fh-muted{color:var(--muted);}
26145 .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;}
26146 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26147 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26148 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26149 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26150 .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;}
26151 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26152 .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;}
26153 .btn-secondary:hover{background:var(--line);}
26154 .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;}
26155 .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;}
26156 .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;}
26157 @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));}}
26158 .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;}
26159 .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;}
26160 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26161 </style>
26162</head>
26163<body>
26164 <div class="background-watermarks" aria-hidden="true">
26165 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26166 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26167 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26168 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26169 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26170 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26171 </div>
26172 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26173 <div class="top-nav">
26174 <div class="top-nav-inner">
26175 <a class="brand" href="/">
26176 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26177 <div class="brand-copy">
26178 <div class="brand-title">OxideSLOC</div>
26179 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26180 </div>
26181 </a>
26182 <div class="nav-right">
26183 <a class="nav-pill" href="/">Home</a>
26184 <div class="nav-dropdown">
26185 <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>
26186 <div class="nav-dropdown-menu">
26187 <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>
26188 </div>
26189 </div>
26190 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26191 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26192 <div class="nav-dropdown">
26193 <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>
26194 <div class="nav-dropdown-menu">
26195 <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>
26196 </div>
26197 </div>
26198 <div class="server-status-wrap" id="server-status-wrap">
26199 <div class="nav-pill server-online-pill" id="server-status-pill">
26200 <span class="status-dot" id="status-dot"></span>
26201 <span id="server-status-label">Server</span>
26202 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26203 </div>
26204 <div class="server-status-tip">
26205 OxideSLOC is running — accessible on your network.
26206 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26207 </div>
26208 </div>
26209 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26210 <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>
26211 </button>
26212 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26213 <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>
26214 <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>
26215 </button>
26216 </div>
26217 </div>
26218 </div>
26219
26220 <div class="page">
26221 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26222 <div class="panel">
26223 <h1>Report File Not Found</h1>
26224 <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>
26225 <div class="field-label">Missing file</div>
26226 <div class="filename-chip">
26227 <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>
26228 {{ expected_filename }}
26229 </div>
26230 <div class="locate-section">
26231 <h2>Locate Scan Output Folder</h2>
26232 <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>
26233 <p>OxideSLOC will find the correct files inside automatically.</p>
26234 <div class="locate-row">
26235 <input type="text" id="locate-file-input"
26236 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26237 class="locate-input" autocomplete="off" spellcheck="false">
26238 {% if !server_mode %}
26239 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26240 {% endif %}
26241 </div>
26242 <div class="warning-banner" id="filename-warning">
26243 <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>
26244 <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>
26245 </div>
26246 <div class="error-inline" id="locate-error">
26247 <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>
26248 <span id="locate-error-text"></span>
26249 </div>
26250 <div class="success-inline" id="locate-success">
26251 <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>
26252 <span>Scan restored — loading report…</span>
26253 </div>
26254 <div class="btn-row">
26255 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26256 <a class="btn-secondary" href="/view-reports">View Reports</a>
26257 </div>
26258 <div class="folder-hint-shell">
26259 <div class="folder-hint-hdr">
26260 <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>
26261 Expected Folder Structure — Select the Top-Level Folder
26262 </div>
26263 <div class="folder-hint-body">
26264 <div class="fh-row">
26265 <span class="fh-tog">►</span>
26266 <span class="fh-dir">project_20260601-0029-…/</span>
26267 <span class="fh-badge">← select this</span>
26268 </div>
26269 <div class="fh-row fh-i1">
26270 <span class="fh-tog">►</span>
26271 <span class="fh-dir">html/</span>
26272 </div>
26273 <div class="fh-row fh-i2">
26274 <span class="fh-bul">•</span>
26275 <span class="fh-hl">{{ expected_filename }}</span>
26276 </div>
26277 <div class="fh-row fh-i1">
26278 <span class="fh-tog">►</span>
26279 <span class="fh-dir">json/</span>
26280 </div>
26281 <div class="fh-row fh-i2">
26282 <span class="fh-bul">•</span>
26283 <span class="fh-muted">result_*.json</span>
26284 </div>
26285 <div class="fh-row fh-i1">
26286 <span class="fh-tog">►</span>
26287 <span class="fh-dir">pdf/</span>
26288 </div>
26289 <div class="fh-row fh-i2">
26290 <span class="fh-bul">•</span>
26291 <span class="fh-muted">report_*.pdf</span>
26292 </div>
26293 <div class="fh-row fh-i1">
26294 <span class="fh-tog">►</span>
26295 <span class="fh-dir">excel/</span>
26296 </div>
26297 <div class="fh-row fh-i2">
26298 <span class="fh-bul">•</span>
26299 <span class="fh-muted">report_*.csv report_*.xlsx</span>
26300 </div>
26301 </div>
26302 </div>
26303 </div>
26304 </div>
26305 </div>
26306 <footer class="site-footer">
26307 oxide-sloc v{{ version }} — local code metrics workbench ·
26308 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26309 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26310 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26311 · <a href="/api-docs" rel="noopener">REST API</a>
26312 </footer>
26313 <script nonce="{{ csp_nonce }}">(function(){
26314 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26315 if(s==="dark")b.classList.add("dark-theme");
26316 document.getElementById("theme-toggle").addEventListener("click",function(){
26317 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26318 });
26319 })();</script>
26320 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26321 var c=document.getElementById('code-particles');if(!c)return;
26322 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'];
26323 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);}
26324 })();
26325 (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>
26326 <script nonce="{{ csp_nonce }}">(function(){
26327 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'}];
26328 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);});}
26329 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26330 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');});}
26331 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26332 }());</script>
26333 <script nonce="{{ csp_nonce }}">(function(){
26334 var meta=document.getElementById('locate-meta');
26335 var inp=document.getElementById('locate-file-input');
26336 var browseBtn=document.getElementById('browse-locate-btn');
26337 var submitBtn=document.getElementById('locate-submit-btn');
26338 var warning=document.getElementById('filename-warning');
26339 var errBox=document.getElementById('locate-error');
26340 var errText=document.getElementById('locate-error-text');
26341 var okBox=document.getElementById('locate-success');
26342 var expected=meta?meta.getAttribute('data-expected'):'';
26343 var runId=meta?meta.getAttribute('data-run-id'):'';
26344 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26345 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26346 function showErr(msg){
26347 if(errText){
26348 errText.innerHTML='';
26349 var lines=msg.split('\n');
26350 var hasPairs=lines.some(function(l){return / : /.test(l);});
26351 if(!hasPairs){errText.textContent=msg;}
26352 else{
26353 var frag=document.createDocumentFragment();var tbl=null;
26354 lines.forEach(function(line){
26355 var m=line.match(/^(.*?) : (.*)$/);
26356 if(m){
26357 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26358 var tr=document.createElement('tr');
26359 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26360 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26361 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26362 } else {
26363 tbl=null;
26364 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26365 }
26366 });
26367 errText.appendChild(frag);
26368 }
26369 }
26370 if(errBox)errBox.classList.add('show');
26371 if(okBox)okBox.classList.remove('show');
26372 }
26373 function clearErr(){
26374 if(errBox)errBox.classList.remove('show');
26375 if(okBox)okBox.classList.remove('show');
26376 }
26377 function validate(){
26378 var val=inp?inp.value.trim():'';
26379 clearErr();
26380 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26381 if(submitBtn)submitBtn.disabled=false;
26382 if(warning){
26383 var name=basename(val);
26384 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26385 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26386 else warning.classList.remove('show');
26387 }
26388 }
26389 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26390 if(browseBtn){
26391 browseBtn.addEventListener('click',function(){
26392 browseBtn.disabled=true;browseBtn.textContent='...';
26393 fetch('/pick-directory')
26394 .then(function(r){return r.ok?r.json():{cancelled:true};})
26395 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26396 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26397 });
26398 }
26399 if(submitBtn){
26400 submitBtn.addEventListener('click',function(){
26401 var folder=inp?inp.value.trim():'';
26402 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26403 clearErr();
26404 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26405 var body=new URLSearchParams();
26406 body.set('file_path',folder);
26407 body.set('redirect_url',redirectUrl);
26408 body.set('expected_run_id',runId);
26409 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26410 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26411 .then(function(d){
26412 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26413 if(d&&d.ok){
26414 if(okBox)okBox.classList.add('show');
26415 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26416 } else {
26417 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26418 }
26419 })
26420 .catch(function(e){
26421 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26422 showErr('Network error: '+String(e));
26423 });
26424 });
26425 }
26426 })();</script>
26427 <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>
26428</body>
26429</html>
26430"##,
26431 ext = "html"
26432)]
26433struct LocateFileTemplate {
26434 run_id: String,
26435 artifact_type: String,
26436 expected_filename: String,
26437 server_mode: bool,
26438 csp_nonce: String,
26439 version: &'static str,
26440}
26441
26442#[derive(Template)]
26445#[template(
26446 source = r##"
26447<!doctype html>
26448<html lang="en">
26449<head>
26450 <meta charset="utf-8">
26451 <meta name="viewport" content="width=device-width, initial-scale=1">
26452 <title>OxideSLOC | Locate Scan Files</title>
26453 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26454 <style nonce="{{ csp_nonce }}">
26455 :root {
26456 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26457 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26458 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26459 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26460 }
26461 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26462 *{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;}
26463 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26464 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26465 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26466 .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);}
26467 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26468 .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));}
26469 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26470 .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;}
26471 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26472 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26473 @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;}}
26474 .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;}
26475 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26476 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26477 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26478 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26479 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26480 .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;}
26481 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26482 .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);}
26483 .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;}
26484 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26485 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26486 .settings-modal-body{padding:14px 16px 16px;}
26487 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26488 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26489 .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;}
26490 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26491 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26492 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26493 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26494 .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;}
26495 .tz-select:focus{border-color:var(--oxide);}
26496 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26497 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26498 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26499 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26500 .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;}
26501 .error-box.hidden{display:none;}
26502 .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;}
26503 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26504 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26505 .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;}
26506 .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;}
26507 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26508 .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;}
26509 .btn-secondary:hover{background:var(--line);}
26510 .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;}
26511 .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;}
26512 .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;}
26513 @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));}}
26514 .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;}
26515 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26516 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26517 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26518 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26519 .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;}
26520 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26521 body.dark-theme .relocate-input{background:var(--surface-2);}
26522 </style>
26523</head>
26524<body>
26525 <div class="background-watermarks" aria-hidden="true">
26526 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26527 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26528 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26529 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26530 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26531 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26532 </div>
26533 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26534 <div class="top-nav">
26535 <div class="top-nav-inner">
26536 <a class="brand" href="/">
26537 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26538 <div class="brand-copy">
26539 <div class="brand-title">OxideSLOC</div>
26540 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26541 </div>
26542 </a>
26543 <div class="nav-right">
26544 <a class="nav-pill" href="/">Home</a>
26545 <div class="nav-dropdown">
26546 <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>
26547 <div class="nav-dropdown-menu">
26548 <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>
26549 </div>
26550 </div>
26551 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26552 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26553 <div class="nav-dropdown">
26554 <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>
26555 <div class="nav-dropdown-menu">
26556 <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>
26557 </div>
26558 </div>
26559 <div class="server-status-wrap" id="server-status-wrap">
26560 <div class="nav-pill server-online-pill" id="server-status-pill">
26561 <span class="status-dot" id="status-dot"></span>
26562 <span id="server-status-label">Server</span>
26563 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26564 </div>
26565 <div class="server-status-tip">
26566 OxideSLOC is running — accessible on your network.
26567 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26568 </div>
26569 </div>
26570 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26571 <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>
26572 </button>
26573 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26574 <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>
26575 <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>
26576 </button>
26577 </div>
26578 </div>
26579 </div>
26580
26581 <div class="page">
26582 <div class="panel">
26583 <h1>Scan Files Moved</h1>
26584 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26585 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26586 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26587 <div class="relocate-section">
26588 <h2>Locate Scan Output</h2>
26589 <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>
26590 <div class="relocate-row">
26591 <input type="text" id="relocate-folder" name="folder_path"
26592 value="{{ folder_hint }}"
26593 placeholder="Path to folder containing scan output..."
26594 class="relocate-input" autocomplete="off" spellcheck="false">
26595 {% if !server_mode %}
26596 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
26597 {% endif %}
26598 </div>
26599 <div style="margin-top:12px;">
26600 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26601 </div>
26602 </div>
26603 <div class="actions">
26604 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26605 <a class="btn-secondary" href="/view-reports">View Reports</a>
26606 </div>
26607 </div>
26608 </div>
26609 <footer class="site-footer">
26610 oxide-sloc v{{ version }} — local code metrics workbench ·
26611 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26612 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26613 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26614 · <a href="/api-docs" rel="noopener">REST API</a>
26615 </footer>
26616 <script nonce="{{ csp_nonce }}">
26617 (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");});})();
26618 (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);}})();
26619 (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;});})();
26620 </script>
26621 <script nonce="{{ csp_nonce }}">
26622 (function(){
26623 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'}];
26624 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);});}
26625 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26626 function init(){
26627 var btn=document.getElementById('settings-btn');if(!btn)return;
26628 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26629 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>';
26630 document.body.appendChild(m);
26631 var g=document.getElementById('scheme-grid');
26632 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);});
26633 var cl=document.getElementById('settings-close');
26634 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);});})();
26635 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');});
26636 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26637 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26638 }
26639 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26640 }());
26641 (function(){
26642 var browseBtn=document.getElementById('browse-relocate-btn');
26643 if(browseBtn){
26644 browseBtn.addEventListener('click',function(){
26645 browseBtn.disabled=true;browseBtn.textContent='...';
26646 var inp=document.getElementById('relocate-folder');
26647 var hint=inp?inp.value:'';
26648 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
26649 .then(function(r){return r.ok?r.json():{cancelled:true};})
26650 .then(function(d){
26651 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26652 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26653 })
26654 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26655 });
26656 }
26657 var restoreBtn=document.getElementById('restore-btn');
26658 var errBox=document.getElementById('relocate-error-box');
26659 var okBox=document.getElementById('relocate-success-box');
26660 if(restoreBtn){
26661 restoreBtn.addEventListener('click',function(){
26662 var inp=document.getElementById('relocate-folder');
26663 var folder=inp?inp.value.trim():'';
26664 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26665 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26666 var body=new URLSearchParams();
26667 body.set('run_id','{{ run_id }}');
26668 body.set('redirect_url','{{ redirect_url }}');
26669 body.set('folder_path',folder);
26670 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26671 .then(function(r){return r.json();})
26672 .then(function(d){
26673 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26674 if(d&&d.ok){
26675 if(errBox)errBox.classList.add('hidden');
26676 if(okBox){okBox.style.display='block';}
26677 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26678 } else {
26679 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26680 }
26681 })
26682 .catch(function(e){
26683 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26684 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26685 });
26686 });
26687 }
26688 }());
26689 </script>
26690 <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]';
26691 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;}
26692 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>
26693</body>
26694</html>
26695"##,
26696 ext = "html"
26697)]
26698struct RelocateScanTemplate {
26699 message: String,
26700 run_id: String,
26701 folder_hint: String,
26702 redirect_url: String,
26703 server_mode: bool,
26704 csp_nonce: String,
26705 version: &'static str,
26706}
26707
26708#[derive(Template)]
26711#[template(
26712 source = r##"
26713<!doctype html>
26714<html lang="en">
26715<head>
26716 <meta charset="utf-8">
26717 <meta name="viewport" content="width=device-width, initial-scale=1">
26718 <title>OxideSLOC | View Reports</title>
26719 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26720 <style nonce="{{ csp_nonce }}">
26721 :root {
26722 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26723 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26724 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26725 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26726 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26727 }
26728 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; }
26729 *{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;}
26730 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26731 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26732 .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);}
26733 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26734 .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));}
26735 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26736 .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;}
26737 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26738 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26739 @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; } }
26740 .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;}
26741 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26742 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26743 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26744 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26745 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26746 .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;}
26747 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26748 .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);}
26749 .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;}
26750 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26751 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26752 .settings-modal-body{padding:14px 16px 16px;}
26753 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26754 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26755 .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;}
26756 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26757 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26758 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26759 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26760 .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;}
26761 .tz-select:focus{border-color:var(--oxide);}
26762 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26763 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26764 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26765 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26766 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26767 .panel-meta{font-size:13px;color:var(--muted);}
26768 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26769 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26770 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26771 .per-page-label{font-size:13px;color:var(--muted);}
26772 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;}
26773 .filter-input{min-width:180px;cursor:text;}
26774 .table-wrap{width:100%;overflow-x:auto;}
26775 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26776 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;}
26777 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26778 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26779 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26780 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26781 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26782 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26783 tr:last-child td{border-bottom:none;}
26784 tr:hover td{background:var(--surface-2);}
26785 .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);}
26786 .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);}
26787 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26788 .metric-num{font-weight:700;color:var(--text);}
26789 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26790 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26791 .git-commit-chip{cursor:help;}
26792 .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;}
26793 .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;}
26794 .btn:hover{background:var(--line);}
26795 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26796 .btn.primary:hover{opacity:.9;}
26797 .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;}
26798 .btn-back:hover{background:var(--line);}
26799 .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;}
26800 .export-btn:hover{background:var(--line);}
26801 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26802 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26803 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26804 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26805 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26806 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26807 .pagination-info{font-size:13px;color:var(--muted);}
26808 .pagination-btns{display:flex;gap:6px;}
26809 .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;}
26810 .pg-btn:hover:not(:disabled){background:var(--line);}
26811 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26812 .pg-btn:disabled{opacity:.35;cursor:default;}
26813 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26814 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26815 .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);}
26816 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26817 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26818 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26819 .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);}
26820 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26821 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26822 .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;}
26823 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26824 .site-footer a{color:var(--muted);}
26825 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26826 .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%;}
26827 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26828 .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;}
26829 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26830 .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;}
26831 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26832 .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;}
26833 .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;}
26834 .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;}
26835 @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));}}
26836 .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;}
26837 .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;}
26838 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26839 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26840 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26841 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26842 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26843 .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;}
26844 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26845 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26846 .watched-chip-rm:hover{color:var(--oxide);}
26847 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26848 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26849 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26850 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26851 .rpt-btn{min-width:58px;justify-content:center;}
26852 .flex-row{display:flex;align-items:center;gap:8px;}
26853 .report-cell{overflow:visible;white-space:normal;}
26854 #history-table col:nth-child(1){width:185px;}
26855 #history-table col:nth-child(2){width:220px;}
26856 #history-table col:nth-child(3){width:100px;}
26857 #history-table col:nth-child(4){width:72px;}
26858 #history-table col:nth-child(5){width:82px;}
26859 #history-table col:nth-child(6){width:82px;}
26860 #history-table col:nth-child(7){width:65px;}
26861 #history-table col:nth-child(8){width:90px;}
26862 #history-table col:nth-child(9){width:85px;}
26863 #history-table col:nth-child(10){width:115px;}
26864 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26865 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26866 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26867 .submod-details summary::-webkit-details-marker{display:none;}
26868.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26869 .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;}
26870 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26871 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26872 </style>
26873</head>
26874<body>
26875 <div class="background-watermarks" aria-hidden="true">
26876 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26877 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26878 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26879 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26880 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26881 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26882 </div>
26883 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26884 <div class="top-nav">
26885 <div class="top-nav-inner">
26886 <a class="brand" href="/">
26887 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26888 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26889 </a>
26890 <div class="nav-right">
26891 <a class="nav-pill" href="/">Home</a>
26892 <div class="nav-dropdown">
26893 <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>
26894 <div class="nav-dropdown-menu">
26895 <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>
26896 </div>
26897 </div>
26898 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26899 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26900 <div class="nav-dropdown">
26901 <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>
26902 <div class="nav-dropdown-menu">
26903 <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>
26904 </div>
26905 </div>
26906 <div class="server-status-wrap" id="server-status-wrap">
26907 <div class="nav-pill server-online-pill" id="server-status-pill">
26908 <span class="status-dot" id="status-dot"></span>
26909 <span id="server-status-label">Server</span>
26910 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26911 </div>
26912 <div class="server-status-tip">
26913 OxideSLOC is running — accessible on your network.
26914 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26915 </div>
26916 </div>
26917 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26918 <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>
26919 </button>
26920 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26921 <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>
26922 <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>
26923 </button>
26924 </div>
26925 </div>
26926 </div>
26927
26928 <div class="page">
26929 {% if let Some(err) = browse_error %}
26930 <div class="toast-error">
26931 <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>
26932 {{ err }}
26933 </div>
26934 {% endif %}
26935 {% if linked_count > 0 %}
26936 <div class="toast-success">
26937 <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>
26938 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26939 </div>
26940 {% endif %}
26941 <div class="watched-bar">
26942 <div class="watched-bar-left">
26943 <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>
26944 <span class="watched-label">Watched Folders</span>
26945 <div class="watched-chips">
26946 {% if server_mode %}
26947 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26948 {% else %}
26949 {% for dir in watched_dirs %}
26950 <span class="watched-chip">
26951 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26952 <form method="POST" action="/watched-dirs/remove" style="display:contents">
26953 <input type="hidden" name="folder_path" value="{{ dir }}">
26954 <input type="hidden" name="redirect_to" value="/view-reports">
26955 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
26956 </form>
26957 </span>
26958 {% endfor %}
26959 {% if watched_dirs.is_empty() %}
26960 <span class="watched-none">No folders watched — click Choose to add one</span>
26961 {% endif %}
26962 {% endif %}
26963 </div>
26964 </div>
26965 {% if !server_mode %}
26966 <div class="watched-bar-right">
26967 <button type="button" class="btn" id="add-watched-btn">
26968 <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>
26969 Choose
26970 </button>
26971 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26972 <input type="hidden" name="redirect_to" value="/view-reports">
26973 <button type="submit" class="btn">↻ Refresh</button>
26974 </form>
26975 </div>
26976 {% endif %}
26977 </div>
26978 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
26979 <div class="scan-overlay-card">
26980 <div class="scan-spinner"></div>
26981 <div class="scan-overlay-text">Scanning folder…</div>
26982 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
26983 </div>
26984 </div>
26985 <style>
26986 .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);}
26987 .scan-overlay.active{display:flex;}
26988 .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;}
26989 .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;}
26990 @keyframes scanSpin{to{transform:rotate(360deg);}}
26991 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
26992 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
26993 </style>
26994 {% if total_scans > 0 %}
26995 <div class="summary-strip">
26996 <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>
26997 <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>
26998 <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>
26999 <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>
27000 </div>
27001 {% endif %}
27002
27003 <section class="panel">
27004 <div class="panel-header">
27005 <div>
27006 <h1>View Reports</h1>
27007 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27008 {% 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 %}
27009 </div>
27010 <div class="flex-row">
27011 <button type="button" class="export-btn" id="export-csv-btn">
27012 <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>
27013 Export CSV
27014 </button>
27015 <button type="button" class="export-btn" id="export-xls-btn">
27016 <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>
27017 Export Excel
27018 </button>
27019 </div>
27020 </div>
27021
27022 {% if entries.is_empty() %}
27023 <div class="empty-state">
27024 <strong>No reports with viewable HTML yet</strong>
27025 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.
27026 </div>
27027 {% else %}
27028 <div class="filter-row">
27029 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27030 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27031 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27032 </div>
27033 <div class="table-wrap">
27034 <table id="history-table">
27035 <colgroup>
27036 <col><col><col><col><col><col><col><col><col><col>
27037 </colgroup>
27038 <thead>
27039 <tr id="history-thead">
27040 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27041 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27042 <th>Run ID<div class="col-resize-handle"></div></th>
27043 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27044 <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>
27045 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27046 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27047 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27048 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27049 <th>Report<div class="col-resize-handle"></div></th>
27050 </tr>
27051 </thead>
27052 <tbody id="history-tbody">
27053 {% for entry in entries %}
27054 <tr class="history-row" data-run="{{ entry.run_id }}"
27055 data-timestamp="{{ entry.timestamp }}"
27056 data-project="{{ entry.project_label }}"
27057 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27058 data-skipped="{{ entry.files_skipped }}"
27059 data-comments="{{ entry.comment_lines }}"
27060 data-blank="{{ entry.blank_lines }}"
27061 data-physical="{{ entry.total_physical_lines }}"
27062 data-functions="{{ entry.functions }}"
27063 data-classes="{{ entry.classes }}"
27064 data-variables="{{ entry.variables }}"
27065 data-imports="{{ entry.imports }}"
27066 data-tests="{{ entry.test_count }}"
27067 data-branch="{{ entry.git_branch }}"
27068 data-commit="{{ entry.git_commit }}"
27069 data-has-json="{{ entry.has_json }}"
27070 data-html-url="/runs/html/{{ entry.run_id }}">
27071 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27072 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27073 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27074 <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>
27075 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27076 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27077 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27078 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27079 <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>
27080 <td class="report-cell">
27081 <div class="actions-cell">
27082 {% 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 %}
27083 {% 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 %}
27084 </div>
27085 {% if !entry.submodule_links.is_empty() %}
27086 <details class="submod-details">
27087 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27088 <div class="submod-link-list">
27089 {% for sub in entry.submodule_links %}
27090 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27091 {% endfor %}
27092 </div>
27093 </details>
27094 {% endif %}
27095 </td>
27096 </tr>
27097 {% endfor %}
27098 </tbody>
27099 </table>
27100 </div>
27101 <div class="pagination">
27102 <span class="pagination-info" id="pagination-info"></span>
27103 <div class="pagination-btns" id="pagination-btns"></div>
27104 <div class="flex-row">
27105 <span class="per-page-label">Show</span>
27106 <select class="per-page" id="per-page-sel">
27107 <option value="10">10 per page</option>
27108 <option value="25" selected>25 per page</option>
27109 <option value="50">50 per page</option>
27110 <option value="100">100 per page</option>
27111 </select>
27112 <span class="per-page-label" id="page-range-label"></span>
27113 </div>
27114 </div>
27115 {% endif %}
27116 </section>
27117 </div>
27118
27119 <footer class="site-footer">
27120 local code analysis - metrics, history and reports
27121 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27122 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27123 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27124 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27125 · <a href="/api-docs" rel="noopener">REST API</a>
27126 </footer>
27127
27128 <script nonce="{{ csp_nonce }}">
27129 (function () {
27130 // ── Theme ──────────────────────────────────────────────────────────────
27131 var storageKey = 'oxide-sloc-theme';
27132 var body = document.body;
27133 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27134 var toggle = document.getElementById('theme-toggle');
27135 if (toggle) toggle.addEventListener('click', function () {
27136 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27137 body.classList.toggle('dark-theme', next === 'dark');
27138 try { localStorage.setItem(storageKey, next); } catch(e) {}
27139 });
27140
27141 // ── State ─────────────────────────────────────────────────────────────
27142 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27143 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27144 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27145
27146 // Aggregate stats from first (most recent) row
27147 if (allRows.length) {
27148 var first = allRows[0];
27149 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();}
27150 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>':'');}
27151 setChipVal('agg-code', first.dataset.code);
27152 setChipVal('agg-files', first.dataset.files);
27153 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27154 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27155 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(); });
27156 }
27157
27158 // ── Branch filter population ──────────────────────────────────────────
27159 (function() {
27160 var branches = {};
27161 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27162 var sel = document.getElementById('branch-filter');
27163 if (sel) Object.keys(branches).sort().forEach(function(b) {
27164 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27165 });
27166 })();
27167
27168 // ── Filter ────────────────────────────────────────────────────────────
27169 function getFilteredRows() {
27170 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27171 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27172 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27173 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27174 if (branch && (r.dataset.branch || '') !== branch) return false;
27175 return true;
27176 });
27177 }
27178
27179 // ── Pagination ────────────────────────────────────────────────────────
27180 function renderPage() {
27181 var filtered = getFilteredRows();
27182 var total = filtered.length;
27183 var totalPages = Math.max(1, Math.ceil(total / perPage));
27184 currentPage = Math.min(currentPage, totalPages);
27185 var start = (currentPage - 1) * perPage;
27186 var end = Math.min(start + perPage, total);
27187 var shown = {};
27188 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27189 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27190 r.style.display = shown[r.dataset.run] ? '' : 'none';
27191 });
27192 var rl = document.getElementById('page-range-label');
27193 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27194 var info = document.getElementById('pagination-info');
27195 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27196 var btns = document.getElementById('pagination-btns');
27197 if (!btns) return;
27198 btns.innerHTML = '';
27199 function makeBtn(lbl, pg, active, disabled) {
27200 var b = document.createElement('button');
27201 b.className = 'pg-btn' + (active ? ' active' : '');
27202 b.textContent = lbl; b.disabled = disabled;
27203 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27204 return b;
27205 }
27206 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27207 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27208 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27209 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27210 }
27211
27212 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27213 window.applyFilters = function() { currentPage = 1; renderPage(); };
27214
27215 // ── Sorting ───────────────────────────────────────────────────────────
27216 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27217 function doSort(col, type, order) {
27218 var tbody = document.getElementById('history-tbody');
27219 if (!tbody) return;
27220 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27221 rows.sort(function(a, b) {
27222 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27223 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27224 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27225 return va < vb ? 1 : va > vb ? -1 : 0;
27226 });
27227 rows.forEach(function(r) { tbody.appendChild(r); });
27228 currentPage = 1; renderPage();
27229 }
27230 sortHeaders.forEach(function(th) {
27231 th.addEventListener('click', function(e) {
27232 if (e.target.classList.contains('col-resize-handle')) return;
27233 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27234 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27235 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27236 th.classList.add('sort-' + sortOrder);
27237 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27238 doSort(col, type, sortOrder);
27239 });
27240 });
27241
27242 // ── Column resize ─────────────────────────────────────────────────────
27243 (function() {
27244 var table = document.getElementById('history-table');
27245 if (!table) return;
27246 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27247 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27248 ths.forEach(function(th, i) {
27249 var handle = th.querySelector('.col-resize-handle');
27250 if (!handle || !cols[i]) return;
27251 var startX, startW;
27252 handle.addEventListener('mousedown', function(e) {
27253 e.stopPropagation(); e.preventDefault();
27254 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27255 handle.classList.add('dragging');
27256 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27257 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27258 document.addEventListener('mousemove', onMove);
27259 document.addEventListener('mouseup', onUp);
27260 });
27261 });
27262 })();
27263
27264 // ── Full-commit hover tooltip ─────────────────────────────────────────
27265 // The commit chips live inside an overflow:auto table wrapper, which would
27266 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27267 // (escaping the scroll container) and follow the cursor. Event delegation
27268 // keeps it working after pagination/sorting re-renders the rows.
27269 (function() {
27270 var tip = document.createElement('div');
27271 tip.className = 'commit-tip';
27272 tip.setAttribute('role', 'tooltip');
27273 document.body.appendChild(tip);
27274 var shown = false;
27275 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27276 function place(e) {
27277 var pad = 14, r = tip.getBoundingClientRect();
27278 var x = e.clientX + pad, y = e.clientY + pad;
27279 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27280 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27281 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27282 }
27283 function hide() { tip.style.display = 'none'; shown = false; }
27284 document.addEventListener('mouseover', function(e) {
27285 var chip = chipFrom(e.target);
27286 if (!chip) return;
27287 var full = chip.getAttribute('data-full-commit');
27288 if (!full) return;
27289 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27290 });
27291 document.addEventListener('mousemove', function(e) {
27292 if (!shown) return;
27293 if (chipFrom(e.target)) place(e); else hide();
27294 });
27295 document.addEventListener('mouseout', function(e) {
27296 if (chipFrom(e.target)) hide();
27297 });
27298 })();
27299
27300 // ── Reset view ────────────────────────────────────────────────────────
27301 window.resetView = function() {
27302 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27303 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27304 sortCol = null; sortOrder = 'asc';
27305 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27306 var tbody = document.getElementById('history-tbody');
27307 if (tbody) {
27308 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27309 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27310 rows.forEach(function(r) { tbody.appendChild(r); });
27311 }
27312 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27313 var table = document.getElementById('history-table');
27314 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27315 currentPage = 1; renderPage();
27316 };
27317
27318 renderPage();
27319
27320 // ── Export helpers ────────────────────────────────────────────────────
27321 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
27322 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27323 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);}
27324 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;');}
27325 function slocXlsx(fname,sheet,hdrs,rows){
27326 var enc=new TextEncoder();
27327 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;}
27328 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;}
27329 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27330 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27331 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27332 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;}
27333 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27334 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];}
27335 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27336 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27337 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27338 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27339 +'<fonts count="2">'
27340 +'<font><sz val="11"/><name val="Calibri"/></font>'
27341 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27342 +'</fonts>'
27343 +'<fills count="3">'
27344 +'<fill><patternFill patternType="none"/></fill>'
27345 +'<fill><patternFill patternType="gray125"/></fill>'
27346 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27347 +'</fills>'
27348 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27349 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27350 +'<cellXfs count="4">'
27351 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27352 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27353 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27354 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27355 +'</cellXfs>'
27356 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27357 +'</styleSheet>';
27358 var rx='<row r="1">';
27359 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27360 rx+='</row>';
27361 rows.forEach(function(row,ri){
27362 var rn=ri+2;rx+='<row r="'+rn+'">';
27363 row.forEach(function(cell,c){
27364 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27365 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27366 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27367 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27368 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27369 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27370 });
27371 rx+='</row>';
27372 });
27373 var lastCol=hdrs.length,lastRow=rows.length+1;
27374 var tableRef='A1:'+colNm(lastCol)+lastRow;
27375 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27376 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27377 +'<autoFilter ref="'+tableRef+'"/>'
27378 +'<tableColumns count="'+lastCol+'">'
27379 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27380 +'</tableColumns>'
27381 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27382 +'</table>';
27383 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27384 +'<Relationships xmlns="'+pns+'relationships">'
27385 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27386 +'</Relationships>';
27387 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>';
27388 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27389 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27390 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27391 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27392 +'</worksheet>';
27393 var F={
27394 '[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>',
27395 '_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>',
27396 '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>',
27397 '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>',
27398 'xl/styles.xml':stl,
27399 'xl/sharedStrings.xml':ssXml,
27400 'xl/worksheets/sheet1.xml':sh,
27401 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27402 'xl/tables/table1.xml':tableXml
27403 };
27404 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'];
27405 var zparts=[],zcds=[],zoff=0,znf=0;
27406 order.forEach(function(name){
27407 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27408 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]);
27409 var entry=new Uint8Array(lha.length+nb.length+sz);
27410 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27411 zparts.push(entry);
27412 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));
27413 var cde=new Uint8Array(cda.length+nb.length);
27414 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27415 zcds.push(cde);zoff+=entry.length;znf++;
27416 });
27417 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27418 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]);
27419 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27420 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27421 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27422 zout.set(new Uint8Array(ea),zpos);
27423 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27424 }
27425
27426 // Multi-sheet XLSX builder for the scan-history export.
27427 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27428 function slocXlsxMulti(fname,sheets){
27429 var enc=new TextEncoder();
27430 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;}
27431 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;}
27432 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27433 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27434 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27435 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];}
27436 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;}
27437 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27438 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27439 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27440 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27441 +'<fonts count="3">'
27442 +'<font><sz val="11"/><name val="Calibri"/></font>'
27443 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27444 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27445 +'</fonts>'
27446 +'<fills count="4">'
27447 +'<fill><patternFill patternType="none"/></fill>'
27448 +'<fill><patternFill patternType="gray125"/></fill>'
27449 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27450 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27451 +'</fills>'
27452 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27453 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27454 +'<cellXfs count="7">'
27455 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27456 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27457 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27458 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27459 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27460 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27461 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27462 +'</cellXfs>'
27463 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27464 +'</styleSheet>';
27465 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27466 sheets.forEach(function(sh,sheetIdx){
27467 var rx='<row r="1">';
27468 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27469 rx+='</row>';
27470 var rn=2;
27471 sh.rows.forEach(function(row){
27472 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27473 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27474 rx+='<row r="'+rn+'">';
27475 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27476 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27477 rx+='</row>';rn++;return;
27478 }
27479 rx+='<row r="'+rn+'">';
27480 row.forEach(function(cell,c){
27481 var ref=colRef(c,rn);
27482 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27483 if(typeof cell==='object'&&cell!==null){
27484 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27485 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27486 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27487 return;
27488 }
27489 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27490 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27491 });
27492 rx+='</row>';rn++;
27493 });
27494 var cw='';
27495 if(sh.colWidths&&sh.colWidths.length>0){
27496 cw='<cols>';
27497 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27498 cw+='</cols>';
27499 }
27500 var tblParts='';
27501 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27502 tableCounter++;
27503 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27504 var tRef='A1:'+colNm(colCount)+rowCount;
27505 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27506 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27507 +'<autoFilter ref="'+tRef+'"/>'
27508 +'<tableColumns count="'+colCount+'">'
27509 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27510 +'</tableColumns>'
27511 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27512 +'</table>';
27513 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27514 +'<Relationships xmlns="'+pns+'relationships">'
27515 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27516 +'</Relationships>';
27517 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27518 }
27519 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27520 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27521 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27522 });
27523 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>';
27524 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27525 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27526 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>';
27527 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27528 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27529 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27530 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27531 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27532 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27533 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};
27534 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27535 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27536 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27537 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27538 var zparts=[],zcds=[],zoff=0,znf=0;
27539 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++;});
27540 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27541 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]);
27542 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27543 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27544 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27545 zout.set(new Uint8Array(ea),zpos);
27546 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27547 }
27548
27549 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'};
27550 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27551
27552 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'];
27553 function getHistoryRows(){
27554 var r=[];
27555 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27556 var code=Number(tr.getAttribute('data-code'))||0;
27557 var phys=Number(tr.getAttribute('data-physical'))||0;
27558 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27559 r.push([
27560 tr.getAttribute('data-timestamp')||'',
27561 tr.getAttribute('data-project')||'',
27562 tr.getAttribute('data-run')||'',
27563 tr.getAttribute('data-physical')||'',
27564 tr.getAttribute('data-code')||'',
27565 tr.getAttribute('data-comments')||'',
27566 tr.getAttribute('data-blank')||'',
27567 tr.getAttribute('data-files')||'',
27568 tr.getAttribute('data-skipped')||'',
27569 tr.getAttribute('data-functions')||'',
27570 tr.getAttribute('data-classes')||'',
27571 tr.getAttribute('data-variables')||'',
27572 tr.getAttribute('data-imports')||'',
27573 tr.getAttribute('data-tests')||'',
27574 dens,
27575 tr.getAttribute('data-branch')||'',
27576 tr.getAttribute('data-commit')||''
27577 ]);
27578 });
27579 return r;
27580 }
27581 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27582 window.exportHistoryXls = function(){
27583 var histRows=getHistoryRows();
27584 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27585 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]];});
27586 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]};
27587 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27588 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27589 var runId=jsonRow.getAttribute('data-run')||'';
27590 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27591 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27592 fetch('/runs/json/'+runId)
27593 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27594 .then(function(run){
27595 var tot=run.summary_totals||{};
27596 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27597 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27598 function B(v){return{v:v,s:4};}
27599 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27600 var sumRows=[
27601 [{_sec:true,v:'RUN INFORMATION'}],
27602 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27603 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27604 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27605 [B('Branch'),run.git_branch||''],
27606 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27607 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27608 [B('Files Analyzed'),N(tot.files_analyzed)],
27609 [B('Files Skipped'),N(tot.files_skipped)],
27610 [],
27611 [{_sec:true,v:'CODE METRICS'}],
27612 [B('Physical Lines'),N(phys)],
27613 [B('Code Lines'),N(code)],
27614 [B('Comments'),N(tot.comment_lines)],
27615 [B('Blank Lines'),N(tot.blank_lines)],
27616 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27617 [B('Functions'),N(tot.functions)],
27618 [B('Classes / Types'),N(tot.classes)],
27619 [B('Variables'),N(tot.variables)],
27620 [B('Imports'),N(tot.imports)],
27621 [B('Tests'),N(tot.test_count)],
27622 [B('Assertions'),N(tot.test_assertion_count)],
27623 [B('Test Suites'),N(tot.test_suite_count)],
27624 [B('Code Density'),{v:dens,s:6}],
27625 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27626 ];
27627 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27628 var langRows=(run.totals_by_language||[]).map(function(l){
27629 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27630 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27631 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];
27632 });
27633 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27634 var pfRows=(run.per_file_records||[]).map(function(r){
27635 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27636 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];
27637 });
27638 var skHdrs=['File','Status','Size (bytes)'];
27639 var skRows=(run.skipped_file_records||[]).map(function(r){
27640 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27641 });
27642 slocXlsxMulti('scan-history.xlsx',[
27643 histSheet,
27644 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27645 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27646 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27647 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27648 ]);
27649 })
27650 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27651 };
27652
27653 var csvBtn = document.getElementById('export-csv-btn');
27654 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27655 var xlsBtn = document.getElementById('export-xls-btn');
27656 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27657
27658 // ── Remaining CSP-safe event bindings ────────────────────────────────
27659 (function wireEvents() {
27660 var el;
27661 el = document.getElementById('reset-view-btn');
27662 if (el) el.addEventListener('click', window.resetView);
27663 el = document.getElementById('project-filter');
27664 if (el) el.addEventListener('input', window.applyFilters);
27665 el = document.getElementById('branch-filter');
27666 if (el) el.addEventListener('change', window.applyFilters);
27667 el = document.getElementById('per-page-sel');
27668 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27669 (function(){
27670 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');};
27671 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);
27672 })();
27673 el = document.getElementById('add-watched-btn');
27674 if (el) el.addEventListener('click', function() {
27675 fetch('/pick-directory?kind=reports')
27676 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27677 .then(function(data) {
27678 if (!data.cancelled && data.selected_path) {
27679 var form = document.createElement('form');
27680 form.method = 'POST';
27681 form.action = '/watched-dirs/add';
27682 var ri = document.createElement('input');
27683 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27684 var fi = document.createElement('input');
27685 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27686 form.appendChild(ri); form.appendChild(fi);
27687 document.body.appendChild(form);
27688 if (window.__scanOverlay) window.__scanOverlay();
27689 form.submit();
27690 }
27691 })
27692 .catch(function(e) { alert('Could not open folder picker: ' + e); });
27693 });
27694 })();
27695
27696 (function randomizeWatermarks() {
27697 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27698 if (!wms.length) return;
27699 var placed = [];
27700 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;}
27701 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];}
27702 var half=Math.floor(wms.length/2);
27703 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;});
27704 })();
27705
27706 (function spawnCodeParticles() {
27707 var container = document.getElementById('code-particles');
27708 if (!container) return;
27709 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'];
27710 for (var i = 0; i < 38; i++) {
27711 (function(idx) {
27712 var el = document.createElement('span');
27713 el.className = 'code-particle';
27714 el.textContent = snippets[idx % snippets.length];
27715 var left = Math.random() * 94 + 2;
27716 var top = Math.random() * 88 + 6;
27717 var dur = (Math.random() * 10 + 9).toFixed(1);
27718 var delay = (Math.random() * 18).toFixed(1);
27719 var rot = (Math.random() * 26 - 13).toFixed(1);
27720 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27721 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';
27722 container.appendChild(el);
27723 })(i);
27724 }
27725 })();
27726 })();
27727 </script>
27728 <script nonce="{{ csp_nonce }}">
27729 (function(){
27730 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'}];
27731 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);});}
27732 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27733 function init(){
27734 var btn=document.getElementById('settings-btn');if(!btn)return;
27735 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27736 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>';
27737 document.body.appendChild(m);
27738 var g=document.getElementById('scheme-grid');
27739 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);});
27740 var cl=document.getElementById('settings-close');
27741 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);});})();
27742 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');});
27743 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27744 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27745 }
27746 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27747 }());
27748 </script>
27749 <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>
27750</body>
27751</html>
27752"##,
27753 ext = "html"
27754)]
27755struct HistoryTemplate {
27756 version: &'static str,
27757 entries: Vec<HistoryEntryRow>,
27758 total_scans: usize,
27759 linked_count: usize,
27760 browse_error: Option<String>,
27761 watched_dirs: Vec<String>,
27762 csp_nonce: String,
27763 server_mode: bool,
27764}
27765
27766#[derive(Template)]
27769#[template(
27770 source = r##"
27771<!doctype html>
27772<html lang="en">
27773<head>
27774 <meta charset="utf-8">
27775 <meta name="viewport" content="width=device-width, initial-scale=1">
27776 <title>OxideSLOC | Compare Scans</title>
27777 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27778 <style nonce="{{ csp_nonce }}">
27779 :root {
27780 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27781 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27782 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27783 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27784 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27785 }
27786 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27787 *{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;}
27788 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27789 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27790 .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);}
27791 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27792 .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));}
27793 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27794 .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;}
27795 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27796 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27797 @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; } }
27798 .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;}
27799 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27800 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27801 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27802 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27803 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27804 .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;}
27805 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27806 .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);}
27807 .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;}
27808 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27809 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27810 .settings-modal-body{padding:14px 16px 16px;}
27811 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27812 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27813 .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;}
27814 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27815 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27816 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27817 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27818 .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;}
27819 .tz-select:focus{border-color:var(--oxide);}
27820 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27821 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27822 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27823 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27824 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27825 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27826 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27827 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27828 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27829 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27830 .per-page-label{font-size:13px;color:var(--muted);}
27831 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;}
27832 .filter-input{min-width:180px;cursor:text;}
27833 .table-wrap{width:100%;overflow-x:auto;}
27834 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27835 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;}
27836 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27837 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27838 #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;}
27839 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27840 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27841 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27842 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27843 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27844 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27845 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27846 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27847 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27848 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27849 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27850 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27851 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27852 tr:last-child td{border-bottom:none;}
27853 tr.selected td{background:var(--sel-bg);}
27854 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27855 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27856 tr{cursor:pointer;}
27857 tr.row-locked{opacity:.35;cursor:not-allowed;}
27858 tr.row-locked td{pointer-events:none;}
27859 .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;}
27860 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27861 .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;}
27862 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27863 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27864 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27865 .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);}
27866 .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);}
27867 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27868 .metric-num{font-weight:700;color:var(--text);}
27869 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27870 .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;}
27871 .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;}
27872 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27873 .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;}
27874 .btn:hover{background:var(--line);}
27875 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27876 .btn.primary:hover{opacity:.9;}
27877 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27878 .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;}
27879 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27880 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27881 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27882 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27883 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27884 .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;}
27885 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27886 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27887 .watched-chip-rm:hover{color:var(--oxide);}
27888 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27889 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27890 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27891 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27892 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27893 .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;}
27894 .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;}
27895 .btn-back:hover{background:var(--line);}
27896 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27897 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27898 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27899 .pagination-info{font-size:13px;color:var(--muted);}
27900 .pagination-btns{display:flex;gap:6px;}
27901 .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;}
27902 .pg-btn:hover:not(:disabled){background:var(--line);}
27903 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27904 .pg-btn:disabled{opacity:.35;cursor:default;}
27905 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27906 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27907 .site-footer a{color:var(--muted);}
27908 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27909 .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;}
27910 .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;}
27911 .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;}
27912 @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));}}
27913 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27914 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27915 .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);}
27916 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27917 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27918 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27919 .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);}
27920 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27921 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27922 .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;}
27923 .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;}
27924 .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%;}
27925 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27926 .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;}
27927 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27928 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27929 .hidden{display:none!important;}
27930 .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%;}
27931 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27932 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27933 .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;}
27934 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27935 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27936 .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;}
27937 .scope-option:hover{background:var(--line);}
27938 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27939 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27940 .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;}
27941 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27942 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27943 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27944 .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;}
27945 </style>
27946</head>
27947<body>
27948 <div class="background-watermarks" aria-hidden="true">
27949 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27950 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27951 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27952 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27953 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27954 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27955 </div>
27956 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27957 <div class="top-nav">
27958 <div class="top-nav-inner">
27959 <a class="brand" href="/">
27960 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27961 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27962 </a>
27963 <div class="nav-right">
27964 <a class="nav-pill" href="/">Home</a>
27965 <div class="nav-dropdown">
27966 <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>
27967 <div class="nav-dropdown-menu">
27968 <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>
27969 </div>
27970 </div>
27971 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27972 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27973 <div class="nav-dropdown">
27974 <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>
27975 <div class="nav-dropdown-menu">
27976 <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>
27977 </div>
27978 </div>
27979 <div class="server-status-wrap" id="server-status-wrap">
27980 <div class="nav-pill server-online-pill" id="server-status-pill">
27981 <span class="status-dot" id="status-dot"></span>
27982 <span id="server-status-label">Server</span>
27983 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27984 </div>
27985 <div class="server-status-tip">
27986 OxideSLOC is running — accessible on your network.
27987 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27988 </div>
27989 </div>
27990 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27991 <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>
27992 </button>
27993 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27994 <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>
27995 <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>
27996 </button>
27997 </div>
27998 </div>
27999 </div>
28000
28001 <div class="page">
28002 <div class="watched-bar">
28003 <div class="watched-bar-left">
28004 <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>
28005 <span class="watched-label">Watched Folders</span>
28006 <div class="watched-chips">
28007 {% if server_mode %}
28008 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28009 {% else %}
28010 {% for dir in watched_dirs %}
28011 <span class="watched-chip">
28012 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28013 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28014 <input type="hidden" name="folder_path" value="{{ dir }}">
28015 <input type="hidden" name="redirect_to" value="/compare-scans">
28016 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28017 </form>
28018 </span>
28019 {% endfor %}
28020 {% if watched_dirs.is_empty() %}
28021 <span class="watched-none">No folders watched — click Choose to add one</span>
28022 {% endif %}
28023 {% endif %}
28024 </div>
28025 </div>
28026 {% if !server_mode %}
28027 <div class="watched-bar-right">
28028 <button type="button" class="btn" id="add-watched-btn">
28029 <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>
28030 Choose
28031 </button>
28032 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28033 <input type="hidden" name="redirect_to" value="/compare-scans">
28034 <button type="submit" class="btn">↻ Refresh</button>
28035 </form>
28036 </div>
28037 {% endif %}
28038 </div>
28039 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28040 <div class="scan-overlay-card">
28041 <div class="scan-spinner"></div>
28042 <div class="scan-overlay-text">Scanning folder…</div>
28043 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28044 </div>
28045 </div>
28046 <style>
28047 .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);}
28048 .scan-overlay.active{display:flex;}
28049 .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;}
28050 .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;}
28051 @keyframes scanSpin{to{transform:rotate(360deg);}}
28052 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28053 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28054 </style>
28055 {% if total_scans > 0 %}
28056 <div class="summary-strip">
28057 <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>
28058 <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>
28059 <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>
28060 <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>
28061 </div>
28062 {% endif %}
28063 <section class="panel">
28064 <div class="panel-header">
28065 <div>
28066 <h1>Compare Scans</h1>
28067 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28068 </div>
28069 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28070 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28071 <button class="btn primary" id="compare-btn" disabled>
28072 <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>
28073 Compare <span class="sel-count" id="sel-count">0</span> Selected
28074 </button>
28075 </div>
28076 </div>
28077 </div>
28078
28079 {% if entries.is_empty() %}
28080 <div class="empty-state">
28081 <strong>No scans yet</strong>
28082 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.
28083 </div>
28084 {% else %}
28085 <div class="filter-row">
28086 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28087 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28088 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28089 </div>
28090 <div class="scope-panel hidden" id="scope-panel">
28091 <div class="scope-panel-label">
28092 <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>
28093 Compare scope — choose what to include
28094 </div>
28095 <div class="scope-options" id="scope-options"></div>
28096 </div>
28097 {% if total_scans > 0 %}
28098 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28099 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28100 <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>
28101 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28102 </div>
28103 </div>
28104 {% endif %}
28105 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28106 <span class="compare-all-label">
28107 <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>
28108 Quick Compare All
28109 </span>
28110 </div>
28111 <div class="table-wrap">
28112 <table id="compare-table">
28113 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28114 <thead>
28115 <tr id="compare-thead">
28116 <th><div class="col-resize-handle"></div></th>
28117 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28118 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28119 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28120 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28121 <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>
28122 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28123 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28124 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28125 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28126 <th>Submodules<div class="col-resize-handle"></div></th>
28127 </tr>
28128 </thead>
28129 <tbody id="compare-tbody">
28130 {% for entry in entries %}
28131 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28132 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28133 data-project="{{ entry.project_label }}"
28134 data-files="{{ entry.files_analyzed }}"
28135 data-code="{{ entry.code_lines }}"
28136 data-comments="{{ entry.comment_lines }}"
28137 data-blank="{{ entry.blank_lines }}"
28138 data-branch="{{ entry.git_branch }}"
28139 data-commit="{{ entry.git_commit }}"
28140 data-submodules="{{ entry.submodule_names_csv }}">
28141 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28142 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28143 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28144 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28145 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28146 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28147 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28148 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28149 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28150 <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>
28151 <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>
28152 </tr>
28153 {% endfor %}
28154 </tbody>
28155 </table>
28156 </div>
28157 <div class="pagination">
28158 <span class="pagination-info" id="pagination-info"></span>
28159 <div class="pagination-btns" id="pagination-btns"></div>
28160 <div class="flex-row">
28161 <span class="per-page-label">Show</span>
28162 <select class="per-page" id="per-page-sel">
28163 <option value="10">10 per page</option>
28164 <option value="25" selected>25 per page</option>
28165 <option value="50">50 per page</option>
28166 <option value="100">100 per page</option>
28167 </select>
28168 <span class="per-page-label" id="page-range-label"></span>
28169 </div>
28170 </div>
28171 {% endif %}
28172 </section>
28173 </div>
28174
28175 <footer class="site-footer">
28176 local code analysis - metrics, history and reports
28177 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28178 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28179 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28180 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28181 · <a href="/api-docs" rel="noopener">REST API</a>
28182 </footer>
28183
28184 <script nonce="{{ csp_nonce }}">
28185 (function () {
28186 // ── Theme ──────────────────────────────────────────────────────────────
28187 var storageKey = 'oxide-sloc-theme';
28188 var body = document.body;
28189 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28190 var toggle = document.getElementById('theme-toggle');
28191 if (toggle) toggle.addEventListener('click', function () {
28192 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28193 body.classList.toggle('dark-theme', next === 'dark');
28194 try { localStorage.setItem(storageKey, next); } catch(e) {}
28195 });
28196
28197 // ── State ─────────────────────────────────────────────────────────────
28198 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28199 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28200 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28201 window._allCompareRows = allRows;
28202
28203 // ── Stat chips ────────────────────────────────────────────────────────
28204 (function() {
28205 var projects = {}, latestTs = '', latestRow = null;
28206 allRows.forEach(function(r) {
28207 var p = r.dataset.project || ''; if (p) projects[p] = true;
28208 var ts = r.dataset.timestamp || '';
28209 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28210 });
28211 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();}
28212 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>':'');}
28213 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28214 if (latestRow) {
28215 setChipVal('agg-code', latestRow.dataset.code);
28216 setChipVal('agg-files', latestRow.dataset.files);
28217 }
28218 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(); });
28219 })();
28220
28221 // ── Branch filter population ──────────────────────────────────────────
28222 (function() {
28223 var branches = {};
28224 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28225 var sel = document.getElementById('branch-filter');
28226 if (sel) Object.keys(branches).sort().forEach(function(b) {
28227 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28228 });
28229 })();
28230
28231 // ── Filter ────────────────────────────────────────────────────────────
28232 function getFilteredRows() {
28233 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28234 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28235 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28236 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28237 if (branch && (r.dataset.branch || '') !== branch) return false;
28238 return true;
28239 });
28240 }
28241
28242 // ── Pagination ────────────────────────────────────────────────────────
28243 function renderPage() {
28244 var filtered = getFilteredRows();
28245 var total = filtered.length;
28246 var totalPages = Math.max(1, Math.ceil(total / perPage));
28247 currentPage = Math.min(currentPage, totalPages);
28248 var start = (currentPage - 1) * perPage;
28249 var end = Math.min(start + perPage, total);
28250 var shown = {};
28251 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28252 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28253 r.style.display = shown[r.dataset.run] ? '' : 'none';
28254 });
28255 var rl = document.getElementById('page-range-label');
28256 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28257 var info = document.getElementById('pagination-info');
28258 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28259 var btns = document.getElementById('pagination-btns');
28260 if (!btns) return;
28261 btns.innerHTML = '';
28262 function makeBtn(lbl, pg, active, disabled) {
28263 var b = document.createElement('button');
28264 b.className = 'pg-btn' + (active ? ' active' : '');
28265 b.textContent = lbl; b.disabled = disabled;
28266 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28267 return b;
28268 }
28269 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28270 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28271 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28272 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28273 }
28274
28275 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28276 window.applyFilters = function() { currentPage = 1; renderPage(); };
28277
28278 // ── Sorting ───────────────────────────────────────────────────────────
28279 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28280 function doSort(col, type, order) {
28281 var tbody = document.getElementById('compare-tbody');
28282 if (!tbody) return;
28283 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28284 rows.sort(function(a, b) {
28285 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28286 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28287 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28288 return va < vb ? 1 : va > vb ? -1 : 0;
28289 });
28290 rows.forEach(function(r) { tbody.appendChild(r); });
28291 currentPage = 1; renderPage();
28292 }
28293 sortHeaders.forEach(function(th) {
28294 th.addEventListener('click', function(e) {
28295 if (e.target.classList.contains('col-resize-handle')) return;
28296 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28297 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28298 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28299 th.classList.add('sort-' + sortOrder);
28300 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28301 doSort(col, type, sortOrder);
28302 });
28303 });
28304
28305 // Apply default sort (timestamp desc) on initial load
28306 (function() {
28307 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28308 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28309 })();
28310
28311 // ── Column resize ─────────────────────────────────────────────────────
28312 (function() {
28313 var table = document.getElementById('compare-table');
28314 if (!table) return;
28315 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28316 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28317 ths.forEach(function(th, i) {
28318 var handle = th.querySelector('.col-resize-handle');
28319 if (!handle || !cols[i]) return;
28320 var startX, startW;
28321 handle.addEventListener('mousedown', function(e) {
28322 e.stopPropagation(); e.preventDefault();
28323 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28324 handle.classList.add('dragging');
28325 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28326 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28327 document.addEventListener('mousemove', onMove);
28328 document.addEventListener('mouseup', onUp);
28329 });
28330 });
28331 })();
28332
28333 // ── Full-commit hover tooltip ─────────────────────────────────────────
28334 // The commit chips live inside an overflow:auto table wrapper, which would
28335 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28336 // (escaping the scroll container) and follow the cursor. Event delegation
28337 // keeps it working after pagination/sorting re-renders the rows.
28338 (function() {
28339 var tip = document.createElement('div');
28340 tip.className = 'commit-tip';
28341 tip.setAttribute('role', 'tooltip');
28342 document.body.appendChild(tip);
28343 var shown = false;
28344 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28345 function place(e) {
28346 var pad = 14, r = tip.getBoundingClientRect();
28347 var x = e.clientX + pad, y = e.clientY + pad;
28348 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28349 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28350 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28351 }
28352 function hide() { tip.style.display = 'none'; shown = false; }
28353 document.addEventListener('mouseover', function(e) {
28354 var chip = chipFrom(e.target);
28355 if (!chip) return;
28356 var full = chip.getAttribute('data-full-commit');
28357 if (!full) return;
28358 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28359 });
28360 document.addEventListener('mousemove', function(e) {
28361 if (!shown) return;
28362 if (chipFrom(e.target)) place(e); else hide();
28363 });
28364 document.addEventListener('mouseout', function(e) {
28365 if (chipFrom(e.target)) hide();
28366 });
28367 })();
28368
28369 // ── Reset view ────────────────────────────────────────────────────────
28370 window.resetView = function() {
28371 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28372 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28373 sortCol = null; sortOrder = 'asc';
28374 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28375 var tbody = document.getElementById('compare-tbody');
28376 if (tbody) {
28377 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28378 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28379 rows.forEach(function(r) { tbody.appendChild(r); });
28380 }
28381 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28382 var table = document.getElementById('compare-table');
28383 currentPage = 1; renderPage();
28384 currentPage = 1; renderPage();
28385 };
28386
28387 renderPage();
28388 buildCompareAllBar();
28389
28390 // ── Row selection state ───────────────────────────────────────────────
28391 var selected = [];
28392 var lockedProject = null; // project label of first selected scan
28393
28394 function updateCompareBtn() {
28395 var btn = document.getElementById('compare-btn');
28396 var cnt = document.getElementById('sel-count');
28397 if (!btn) return;
28398 btn.disabled = selected.length < 2;
28399 if (cnt) cnt.textContent = selected.length;
28400 }
28401
28402 function applyProjectLock() {
28403 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28404 allRows.forEach(function(r) {
28405 if (lockedProject === null) {
28406 r.classList.remove('row-locked');
28407 } else {
28408 var proj = r.dataset.project || '';
28409 if (proj !== lockedProject) {
28410 r.classList.add('row-locked');
28411 } else {
28412 r.classList.remove('row-locked');
28413 }
28414 }
28415 });
28416 }
28417
28418 function toggleRow(row) {
28419 if (row.classList.contains('row-locked')) return;
28420 var vid = row.dataset.vid || row.dataset.run;
28421 var idx = selected.indexOf(vid);
28422 if (idx >= 0) {
28423 selected.splice(idx, 1);
28424 row.classList.remove('selected');
28425 var b = document.getElementById('badge-' + vid);
28426 if (b) b.textContent = '';
28427 // Release project lock if nothing selected
28428 if (selected.length === 0) lockedProject = null;
28429 } else {
28430 // Set project lock on first selection
28431 if (selected.length === 0) lockedProject = row.dataset.project || null;
28432 selected.push(vid);
28433 row.classList.add('selected');
28434 }
28435 selected.forEach(function(v, i) {
28436 var b = document.getElementById('badge-' + v);
28437 if (b) b.textContent = i + 1;
28438 });
28439 applyProjectLock();
28440 updateCompareBtn();
28441 buildScopePanel();
28442 }
28443
28444 // ── Compare-All bar ───────────────────────────────────────────────────
28445 function buildCompareAllBar() {
28446 var bar = document.getElementById('compare-all-bar');
28447 if (!bar) return;
28448 // Group all rows by project label.
28449 var groups = {};
28450 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28451 // Use all rows from the source data (not just visible).
28452 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28453 // We need ALL rows across all pages, not just the rendered ones.
28454 // Use the underlying allRows array that the pagination JS also uses.
28455 var sourceRows = window._allCompareRows || allRowsAll;
28456 sourceRows.forEach(function(r) {
28457 var proj = r.dataset.project || '';
28458 var vid = r.dataset.vid || r.dataset.run || '';
28459 if (!proj || !vid) return;
28460 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28461 groups[proj].ids.push(vid);
28462 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28463 });
28464 // Build buttons for each project with >= 2 scans.
28465 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28466 if (!keys.length) { bar.style.display = 'none'; return; }
28467 bar.style.display = 'flex';
28468 // Remove old buttons (keep label).
28469 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28470 oldBtns.forEach(function(b) { b.remove(); });
28471 keys.sort();
28472 keys.forEach(function(proj) {
28473 var g = groups[proj];
28474 var btn = document.createElement('button');
28475 btn.className = 'compare-all-btn';
28476 btn.type = 'button';
28477 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28478 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28479 btn.addEventListener('click', function() {
28480 // Sort ids by timestamp (ascending).
28481 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28482 pairs.sort(function(a, b) { return a.ts - b.ts; });
28483 var sorted = pairs.map(function(p) { return p.id; });
28484 if (sorted.length === 2) {
28485 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28486 } else {
28487 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28488 }
28489 });
28490 bar.appendChild(btn);
28491 });
28492 }
28493
28494 // ── Scope panel ───────────────────────────────────────────────────────
28495 var selectedScope = 'all';
28496
28497 function buildScopePanel() {
28498 var panel = document.getElementById('scope-panel');
28499 var opts = document.getElementById('scope-options');
28500 if (!panel || !opts) return;
28501 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28502
28503 // Collect union of submodules from all selected rows.
28504 var allSubs = {};
28505 selected.forEach(function(vid) {
28506 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28507 if (!row) return;
28508 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28509 });
28510 var subList = Object.keys(allSubs).sort();
28511 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28512
28513 panel.classList.remove('hidden');
28514 opts.innerHTML = '';
28515
28516 function makeOption(value, label, title) {
28517 var div = document.createElement('div');
28518 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28519 div.dataset.scopeValue = value;
28520 if (title) div.title = title;
28521 var radio = document.createElement('span');
28522 radio.className = 'scope-option-radio';
28523 var lbl = document.createElement('span');
28524 lbl.textContent = label;
28525 div.appendChild(radio);
28526 div.appendChild(lbl);
28527 div.addEventListener('click', function() {
28528 selectedScope = value;
28529 opts.querySelectorAll('.scope-option').forEach(function(o) {
28530 o.classList.toggle('selected', o.dataset.scopeValue === value);
28531 });
28532 });
28533 return div;
28534 }
28535
28536 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28537 var sep = document.createElement('span');
28538 sep.className = 'scope-option-sep';
28539 opts.appendChild(sep);
28540 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28541 subList.forEach(function(s) {
28542 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28543 });
28544 }
28545
28546 function doCompare() {
28547 if (selected.length < 2) return;
28548 if (selected.length === 2) {
28549 // Two-scan delta (existing flow with scope support).
28550 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28551 if (selectedScope === 'super') url += '&scope=super';
28552 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28553 window.location.href = url;
28554 } else {
28555 // Multi-scan timeline (N >= 3) — pass scope params too.
28556 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28557 if (selectedScope === 'super') url += '&scope=super';
28558 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28559 window.location.href = url;
28560 }
28561 }
28562
28563 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28564 var cbtn = document.getElementById('compare-btn');
28565 if (cbtn) cbtn.addEventListener('click', doCompare);
28566 var pfEl = document.getElementById('project-filter');
28567 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28568 var bfEl = document.getElementById('branch-filter');
28569 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28570 var rvBtn = document.getElementById('reset-view-btn');
28571 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28572 var ppSel = document.getElementById('per-page-sel');
28573 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28574
28575 var cmpTbody = document.getElementById('compare-tbody');
28576 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28577 var row = e.target.closest('.compare-row');
28578 if (row) toggleRow(row);
28579 });
28580
28581 (function randomizeWatermarks() {
28582 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28583 if (!wms.length) return;
28584 var placed = [];
28585 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;}
28586 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];}
28587 var half=Math.floor(wms.length/2);
28588 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;});
28589 })();
28590
28591 (function spawnCodeParticles() {
28592 var container = document.getElementById('code-particles');
28593 if (!container) return;
28594 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'];
28595 for (var i = 0; i < 38; i++) {
28596 (function(idx) {
28597 var el = document.createElement('span');
28598 el.className = 'code-particle';
28599 el.textContent = snippets[idx % snippets.length];
28600 var left = Math.random() * 94 + 2;
28601 var top = Math.random() * 88 + 6;
28602 var dur = (Math.random() * 10 + 9).toFixed(1);
28603 var delay = (Math.random() * 18).toFixed(1);
28604 var rot = (Math.random() * 26 - 13).toFixed(1);
28605 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28606 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';
28607 container.appendChild(el);
28608 })(i);
28609 }
28610 })();
28611
28612 // ── Watched folder picker ─────────────────────────────────────────────
28613 (function(){
28614 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');};
28615 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);
28616 })();
28617 (function() {
28618 var btn = document.getElementById('add-watched-btn');
28619 if (!btn) return;
28620 btn.addEventListener('click', function() {
28621 fetch('/pick-directory?kind=reports')
28622 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28623 .then(function(data) {
28624 if (!data.cancelled && data.selected_path) {
28625 var form = document.createElement('form');
28626 form.method = 'POST';
28627 form.action = '/watched-dirs/add';
28628 var ri = document.createElement('input');
28629 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28630 var fi = document.createElement('input');
28631 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28632 form.appendChild(ri); form.appendChild(fi);
28633 document.body.appendChild(form);
28634 if (window.__scanOverlay) window.__scanOverlay();
28635 form.submit();
28636 }
28637 })
28638 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28639 });
28640 })();
28641
28642 // ── Submodule chip truncation ─────────────────────────────────────────
28643 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28644 var chips = cell.querySelectorAll('.submod-chip');
28645 var MAX = 4;
28646 if (chips.length <= MAX) return;
28647 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28648 var badge = document.createElement('span');
28649 badge.className = 'submod-overflow-badge';
28650 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28651 badge.textContent = '+' + (chips.length - MAX) + ' more';
28652 cell.appendChild(badge);
28653 cell.style.maxHeight = 'none';
28654 });
28655 })();
28656 </script>
28657 <script nonce="{{ csp_nonce }}">
28658 (function(){
28659 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'}];
28660 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);});}
28661 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28662 function init(){
28663 var btn=document.getElementById('settings-btn');if(!btn)return;
28664 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28665 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>';
28666 document.body.appendChild(m);
28667 var g=document.getElementById('scheme-grid');
28668 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);});
28669 var cl=document.getElementById('settings-close');
28670 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);});})();
28671 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');});
28672 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28673 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28674 }
28675 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28676 }());
28677 </script>
28678 <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]';
28679 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;}
28680 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>
28681</body>
28682</html>
28683"##,
28684 ext = "html"
28685)]
28686struct CompareSelectTemplate {
28687 version: &'static str,
28688 entries: Vec<HistoryEntryRow>,
28689 total_scans: usize,
28690 watched_dirs: Vec<String>,
28691 csp_nonce: String,
28692 server_mode: bool,
28693}
28694
28695#[derive(Template)]
28698#[template(
28699 source = r##"
28700<!doctype html>
28701<html lang="en">
28702<head>
28703 <meta charset="utf-8">
28704 <meta name="viewport" content="width=device-width, initial-scale=1">
28705 <title>OxideSLOC | Scan Delta</title>
28706 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28707 <style nonce="{{ csp_nonce }}">
28708 :root {
28709 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28710 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28711 --nav:#283790; --nav-2:#013e6b;
28712 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28713 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28714 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28715 }
28716 body.dark-theme {
28717 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28718 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28719 }
28720 *{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;}
28721 .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);}
28722 .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;}
28723 .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));}
28724 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28725 .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;}
28726 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28727 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28728 @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; } }
28729 .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;}
28730 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28731 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28732 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28733 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28734 .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;}
28735 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28736 .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);}
28737 .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;}
28738 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28739 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28740 .settings-modal-body{padding:14px 16px 16px;}
28741 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28742 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28743 .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;}
28744 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28745 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28746 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28747 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28748 .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;}
28749 .tz-select:focus{border-color:var(--oxide);}
28750 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28751 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28752 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28753 .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;}
28754 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28755 .hero-body{display:block;}
28756 .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;}
28757 .btn-back:hover{background:var(--line);}
28758 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28759 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28760 .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;}
28761 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28762 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;}
28763 .muted{color:var(--muted);font-size:14px;}
28764 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28765 .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;}
28766 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28767 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28768 .vpill-arrow{font-size:20px;color:var(--muted);}
28769 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28770 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28771 .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;}
28772 .delta-card.delta-card-wide{padding:22px 24px;}
28773 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28774 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28775 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28776 .delta-card-from{font-size:15px;color:var(--muted);}
28777 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28778 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28779 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28780 .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%;}
28781 .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;}
28782 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28783 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28784 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28785 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28786 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28787 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28788 .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;}
28789 .meta-card-commit:hover{color:var(--oxide);}
28790 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28791 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28792 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28793 .meta-value{color:var(--text);font-size:13px;}
28794 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28795 .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;}
28796 .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);}
28797 .delta-card:hover .dc-tip{display:block;}
28798 .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;}
28799 .export-btn:hover{background:var(--line);}
28800 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28801 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28802 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28803 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28804 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28805 .delta-card-change.zero{color:var(--muted);background:transparent;}
28806 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28807 .delta-card-pct.pos{color:var(--pos);}
28808 .delta-card-pct.neg{color:var(--neg);}
28809 .delta-card-pct.zero{color:var(--muted);}
28810 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28811 .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;}
28812 .insight-card.insight-flag{border-color:var(--oxide);}
28813 .insight-card:hover .dc-tip{display:block;}
28814 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28815 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28816 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28817 .insight-label.flag{color:var(--oxide);}
28818 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28819 .insight-val.pos{color:var(--pos);}
28820 .insight-val.neg{color:var(--neg);}
28821 .insight-val.high{color:#c0392a;}
28822 .insight-val.med{color:#926000;}
28823 .insight-val.low{color:var(--pos);}
28824 body.dark-theme .insight-val.high{color:#ff6b6b;}
28825 body.dark-theme .insight-val.med{color:#f0c060;}
28826 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28827 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28828 .fc-row{display:flex;align-items:center;gap:8px;}
28829 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28830 .fc-label{color:var(--muted);}
28831 .fc-modified .fc-count{color:#926000;}
28832 .fc-added .fc-count{color:var(--pos);}
28833 .fc-removed .fc-count{color:var(--neg);}
28834 .fc-unchanged .fc-count{color:var(--muted);}
28835 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28836 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28837 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28838 .chip.modified{background:#fff2d8;color:#926000;}
28839 .chip.added{background:#e8f5ed;color:#1a8f47;}
28840 .chip.removed{background:#fdeaea;color:#b33b3b;}
28841 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28842 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28843 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28844 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28845 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28846 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28847 .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;}
28848 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28849 .tab-btn:hover:not(.active){background:var(--line);}
28850 .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;}
28851 .btn-reset:hover{background:var(--line);}
28852 .table-wrap{width:100%;overflow-x:auto;}
28853 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28854 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);}
28855 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28856 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28857 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28858 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28859 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28860 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28861 tr:last-child td{border-bottom:none;}
28862 tr:hover td{background:var(--surface-2);}
28863 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28864 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28865 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28866 /* Fixed layout: column widths come from the colgroup, not from scanning every
28867 row. With auto layout a large file matrix forces the browser to re-measure
28868 all cells on each reflow, which freezes the page during sort/resize. */
28869 #delta-table{table-layout:fixed;}
28870 #delta-table col:nth-child(1){width:32%;}
28871 #delta-table col:nth-child(2){width:11%;}
28872 #delta-table col:nth-child(3){width:11%;}
28873 #delta-table col:nth-child(4){width:16%;}
28874 #delta-table col:nth-child(5){width:10%;}
28875 #delta-table col:nth-child(6){width:10%;}
28876 #delta-table col:nth-child(7){width:10%;}
28877 tr.row-added td{background:rgba(26,143,71,0.04);}
28878 tr.row-removed td{background:rgba(179,59,59,0.06);}
28879 tr.row-modified td{background:rgba(146,96,0,0.04);}
28880 tr.row-unchanged td{color:var(--muted);}
28881 tr.row-unchanged .status-badge{opacity:.65;}
28882 .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;}
28883 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28884 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28885 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28886 .status-badge.modified{background:#fff2d8;color:#926000;}
28887 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28888 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28889 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28890 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28891 .delta-val{font-weight:700;}
28892 .delta-val.pos{color:var(--pos);}
28893 .delta-val.neg{color:var(--neg);}
28894 .delta-val.zero{color:var(--muted);}
28895 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28896 .from-to strong{color:var(--text);font-weight:700;}
28897 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28898 .from-to .ft-absent{color:var(--muted);font-weight:600;}
28899 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28900 .site-footer a{color:var(--muted);}
28901 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;}
28902 body.pdf-mode{background:#fff!important;}
28903 body.pdf-mode .page{padding:4px 6px 4px!important;}
28904 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28905 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28906 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28907 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28908 .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;}
28909 .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;}
28910 .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;}
28911 @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));}}
28912 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28913 .path-link:hover{color:var(--oxide-2);}
28914 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28915 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28916 a.vpill-id:hover{color:var(--oxide);}
28917 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28918 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28919 .pagination-info{font-size:13px;color:var(--muted);}
28920 .pagination-btns{display:flex;gap:6px;}
28921 .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;}
28922 .pg-btn:hover:not(:disabled){background:var(--line);}
28923 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28924 .pg-btn:disabled{opacity:.35;cursor:default;}
28925 .per-page-label{font-size:13px;color:var(--muted);}
28926 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;}
28927 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28928 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28929 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28930 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28931 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28932 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28933 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28934 .tab-btn.tab-unchanged{color:var(--muted);}
28935 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28936 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28937 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28938 .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;}
28939 .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;}
28940 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28941 .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;}
28942 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28943 .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;}
28944 .submod-scope-btn:hover{background:var(--line);}
28945 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28946 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28947 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28948 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28949 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28950 body.dark-theme .ic-card{background:var(--surface-2);}
28951 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28952 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28953 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28954 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28955 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28956 .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);}
28957 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28958 .ic-card-h2-row .ic-card-h2{margin:0;}
28959 .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;}
28960 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28961 .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;}
28962 .ic-svg-modal-ov.open{display:flex;}
28963 .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);}
28964 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28965 .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);}
28966 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28967 .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;}
28968 .ic-svg-modal-close:hover{background:var(--line);}
28969 .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;}
28970 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28971 .chart-metric-btn:hover:not(.active){background:var(--line);}
28972 .chart-wrap{width:100%;overflow-x:auto;}
28973 #cmp-tl-svg{display:block;width:100%;}
28974 .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);}
28975 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28976 #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;}
28977 </style>
28978</head>
28979<body>
28980 {{ loading_overlay|safe }}
28981 <div class="background-watermarks" aria-hidden="true">
28982 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28983 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28984 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28985 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28986 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28987 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28988 </div>
28989 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28990 <div class="top-nav">
28991 <div class="top-nav-inner">
28992 <a class="brand" href="/">
28993 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28994 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28995 </a>
28996 <div class="nav-right">
28997 <a class="nav-pill" href="/">Home</a>
28998 <div class="nav-dropdown">
28999 <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>
29000 <div class="nav-dropdown-menu">
29001 <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>
29002 </div>
29003 </div>
29004 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29005 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29006 <div class="nav-dropdown">
29007 <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>
29008 <div class="nav-dropdown-menu">
29009 <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>
29010 </div>
29011 </div>
29012 <div class="server-status-wrap" id="server-status-wrap">
29013 <div class="nav-pill server-online-pill" id="server-status-pill">
29014 <span class="status-dot" id="status-dot"></span>
29015 <span id="server-status-label">Server</span>
29016 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29017 </div>
29018 <div class="server-status-tip">
29019 OxideSLOC is running — accessible on your network.
29020 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29021 </div>
29022 </div>
29023 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29024 <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>
29025 </button>
29026 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29027 <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>
29028 <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>
29029 </button>
29030 </div>
29031 </div>
29032 </div>
29033
29034 <div class="page">
29035 <section class="hero">
29036 <div class="hero-header">
29037 <div>
29038 <h1 class="delta-title">Scan Delta</h1>
29039 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29040 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29041 {% if let Some(sub) = active_submodule %}
29042 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29043 {% else if super_scope_active %}
29044 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29045 {% else %}
29046 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29047 {% endif %}
29048 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29049 </div>
29050 </div>
29051 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29052 <a class="btn-back" href="/compare-scans">
29053 <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>
29054 Compare Scans
29055 </a>
29056 <div class="export-group" style="margin-top:12px;">
29057 <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>
29058 <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>
29059 </div>
29060 </div>
29061 </div>
29062 {% if has_any_submodule_data %}
29063 <div class="submod-scope-bar">
29064 <span class="submod-scope-label">
29065 <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>
29066 Scope:
29067 </span>
29068 <div class="submod-scope-divider"></div>
29069 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29070 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29071 title="All files — super-repo and all submodules combined">Full scan</a>
29072 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29073 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29074 title="Only files that are not part of any submodule">Super-repo only</a>
29075 {% for sub in submodule_options %}
29076 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29077 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29078 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29079 {% endfor %}
29080 </div>
29081 {% endif %}
29082 <div class="hero-body">
29083 <div class="meta-strip">
29084 <div class="delta-card delta-card-meta">
29085 <div class="meta-card-header">
29086 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29087 <div class="meta-card-project-col">
29088 <div class="meta-card-project">{{ project_name }}</div>
29089 {% if has_any_submodule_data %}
29090 {% if let Some(sub) = active_submodule %}
29091 <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>
29092 {% else if super_scope_active %}
29093 <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>
29094 {% else %}
29095 <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>
29096 {% endif %}
29097 {% endif %}
29098 </div>
29099 </div>
29100 {% if !baseline_git_commit.is_empty() %}
29101 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29102 {% else %}
29103 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29104 {% endif %}
29105 <div class="meta-card-rows">
29106 <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>
29107 <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>
29108 <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>
29109 <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>
29110 {% if let Some(tags) = baseline_git_tags %}
29111 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29112 {% endif %}
29113 </div>
29114 </div>
29115 <div class="delta-card delta-card-meta">
29116 <div class="meta-card-header">
29117 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29118 <div class="meta-card-project-col">
29119 <div class="meta-card-project">{{ project_name }}</div>
29120 {% if has_any_submodule_data %}
29121 {% if let Some(sub) = active_submodule %}
29122 <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>
29123 {% else if super_scope_active %}
29124 <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>
29125 {% else %}
29126 <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>
29127 {% endif %}
29128 {% endif %}
29129 </div>
29130 </div>
29131 {% if !current_git_commit.is_empty() %}
29132 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29133 {% else %}
29134 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29135 {% endif %}
29136 <div class="meta-card-rows">
29137 <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>
29138 <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>
29139 <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>
29140 <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>
29141 {% if let Some(tags) = current_git_tags %}
29142 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29143 {% endif %}
29144 </div>
29145 </div>
29146 </div>
29147 <div class="delta-strip">
29148 <div class="delta-card">
29149 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29150 <div class="delta-card-label">Code lines</div>
29151 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29152 <div class="delta-card-to">{{ current_code_fmt }}</div>
29153 {% 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>
29154 {% 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>
29155 {% else %}<div class="delta-card-pct zero">±0%</div>
29156 {% endif %}
29157 </div>
29158 <div class="delta-card">
29159 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29160 <div class="delta-card-label">Files analyzed</div>
29161 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29162 <div class="delta-card-to">{{ current_files_fmt }}</div>
29163 {% 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>
29164 {% 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>
29165 {% else %}<div class="delta-card-pct zero">±0%</div>
29166 {% endif %}
29167 </div>
29168 <div class="delta-card">
29169 <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>
29170 <div class="delta-card-label">Comment lines</div>
29171 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29172 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29173 {% 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>
29174 {% 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>
29175 {% else %}<div class="delta-card-pct zero">±0%</div>
29176 {% endif %}
29177 </div>
29178 {{ coverage_delta_card|safe }}
29179 <div class="delta-card delta-card-wide">
29180 <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>
29181 <div class="delta-card-label">File changes</div>
29182 <div class="file-changes-grid">
29183 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29184 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29185 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29186 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29187 </div>
29188 </div>
29189 </div>
29190 <div class="insights-panel">
29191 <div class="insight-card">
29192 <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>
29193 <div class="insight-label">Lines Added</div>
29194 <div class="insight-val pos">+{{ code_lines_added }}</div>
29195 <div class="insight-sub">New or grown source lines</div>
29196 </div>
29197 <div class="insight-card">
29198 <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>
29199 <div class="insight-label">Lines Removed</div>
29200 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29201 <div class="insight-sub">Deleted or shrunk source lines</div>
29202 </div>
29203 <div class="insight-card">
29204 <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>
29205 <div class="insight-label">Lines Modified</div>
29206 <div class="insight-val">{{ code_lines_modified }}</div>
29207 <div class="insight-sub">Code lines in modified files</div>
29208 </div>
29209 <div class="insight-card">
29210 <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>
29211 <div class="insight-label">Lines Unmodified</div>
29212 <div class="insight-val">{{ code_lines_unmodified }}</div>
29213 <div class="insight-sub">Code lines in unchanged files</div>
29214 </div>
29215 <div class="insight-card">
29216 <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>
29217 <div class="insight-label">Churn Rate</div>
29218 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29219 <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>
29220 </div>
29221 {% if scope_flag %}
29222 <div class="insight-card insight-flag">
29223 <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>
29224 <div class="insight-label flag">Scope Signal</div>
29225 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29226 <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>
29227 </div>
29228 {% endif %}
29229 </div>
29230 </div>
29231 </section>
29232
29233 <section class="panel" id="inline-charts-section">
29234 <div class="panel-title">Scan Delta Charts</div>
29235 <div class="ic-grid">
29236 <div class="ic-card" style="grid-column:span 2">
29237 <div class="ic-card-h2-row">
29238 <span class="ic-card-h2">Timeline</span>
29239 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29240 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29241 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29242 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29243 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29244 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29245 </div>
29246 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29247 </div>
29248 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29249 </div>
29250 <div class="ic-card">
29251 <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>
29252 <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>
29253 <div id="ic-c1"></div>
29254 </div>
29255 <div class="ic-card" id="ic-lang-card">
29256 <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>
29257 <div id="ic-c3"></div>
29258 </div>
29259 <div class="ic-card">
29260 <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>
29261 <div id="ic-c2"></div>
29262 </div>
29263 <div class="ic-card">
29264 <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>
29265 <div id="ic-c4"></div>
29266 </div>
29267 </div>
29268 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29269 <div class="ic-svg-modal">
29270 <div class="ic-svg-modal-hdr">
29271 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29272 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
29273 </div>
29274 <div id="ic-svg-modal-body"></div>
29275 </div>
29276 </div>
29277 </section>
29278
29279 <section class="panel">
29280 <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>
29281 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29282 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29283 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29284 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29285 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29286 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29287 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29288 </div>
29289 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29290 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
29291 <div class="export-group">
29292 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
29293 <button type="button" class="export-btn" id="delta-csv-btn">
29294 <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>
29295 CSV
29296 </button>
29297 <button type="button" class="export-btn" id="delta-xls-btn">
29298 <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>
29299 Excel
29300 </button>
29301 </div>
29302 </div>
29303 </div>
29304
29305 <div class="table-wrap">
29306 <table id="delta-table">
29307 <colgroup>
29308 <col>
29309 <col>
29310 <col>
29311 <col>
29312 <col>
29313 <col>
29314 <col>
29315 </colgroup>
29316 <thead>
29317 <tr id="delta-thead">
29318 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29319 <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>
29320 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29321 <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>
29322 <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>
29323 <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>
29324 <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>
29325 </tr>
29326 </thead>
29327 <tbody id="delta-tbody">
29328 {% for row in file_rows %}
29329 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29330 data-path="{{ row.relative_path }}"
29331 data-language="{{ row.language }}"
29332 data-baseline-code="{{ row.baseline_code }}"
29333 data-current-code="{{ row.current_code }}"
29334 data-code-delta="{{ row.code_delta_str }}"
29335 data-comment-delta="{{ row.comment_delta_str }}"
29336 data-total-delta="{{ row.total_delta_str }}"
29337 data-orig-idx="">
29338 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29339 <td class="hide-sm">{{ row.language }}</td>
29340 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29341 <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>
29342 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29343 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29344 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29345 </tr>
29346 {% endfor %}
29347 </tbody>
29348 </table>
29349 </div>
29350 <div class="pagination">
29351 <span class="pagination-info" id="pg-range-label"></span>
29352 <div class="pagination-btns" id="pg-btns"></div>
29353 <div class="flex-row">
29354 <span class="per-page-label">Show</span>
29355 <select class="per-page" id="per-page-sel">
29356 <option value="10">10 per page</option>
29357 <option value="25" selected>25 per page</option>
29358 <option value="50">50 per page</option>
29359 <option value="100">100 per page</option>
29360 </select>
29361 </div>
29362 </div>
29363 </section>
29364 </div>
29365
29366 <div id="ic-tt"></div>
29367
29368 <footer class="site-footer">
29369 local code analysis - metrics, history and reports
29370 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
29371 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29372 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29373 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29374 · <a href="/api-docs" rel="noopener">REST API</a>
29375 </footer>
29376
29377 <script nonce="{{ csp_nonce }}">
29378 (function () {
29379 var storageKey = 'oxide-sloc-theme';
29380 var body = document.body;
29381 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29382 var toggle = document.getElementById('theme-toggle');
29383 if (toggle) toggle.addEventListener('click', function () {
29384 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29385 body.classList.toggle('dark-theme', next === 'dark');
29386 try { localStorage.setItem(storageKey, next); } catch(e) {}
29387 });
29388
29389 (function randomizeWatermarks() {
29390 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29391 if (!wms.length) return;
29392 var placed = [];
29393 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;}
29394 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];}
29395 var half=Math.floor(wms.length/2);
29396 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;});
29397 })();
29398
29399 (function spawnCodeParticles() {
29400 var container = document.getElementById('code-particles');
29401 if (!container) return;
29402 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'];
29403 for (var i = 0; i < 38; i++) {
29404 (function(idx) {
29405 var el = document.createElement('span');
29406 el.className = 'code-particle';
29407 el.textContent = snippets[idx % snippets.length];
29408 var left = Math.random() * 94 + 2;
29409 var top = Math.random() * 88 + 6;
29410 var dur = (Math.random() * 10 + 9).toFixed(1);
29411 var delay = (Math.random() * 18).toFixed(1);
29412 var rot = (Math.random() * 26 - 13).toFixed(1);
29413 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29414 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';
29415 container.appendChild(el);
29416 })(i);
29417 }
29418 })();
29419 })();
29420
29421 var activeStatusFilter = 'all';
29422 var deltaPerPage = 25, deltaCurrPage = 1;
29423
29424 function openFolder(path) {
29425 fetch('/open-path?path=' + encodeURIComponent(path))
29426 .then(function (r) { return r.json(); })
29427 .then(function (d) {
29428 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29429 })
29430 .catch(function () {});
29431 }
29432
29433 // \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
29434 // The server renders every row once; we lift them into a plain-data array and
29435 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29436 // filtering run on the array (no DOM churn) and each render rebuilds just one
29437 // page (~25 rows). This keeps every interaction O(page) instead of O(all
29438 // files): a 28k-row table previously re-touched every node on each click
29439 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29440 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29441
29442 function parseDeltaNum(str) {
29443 if (!str || str === '\u2014') return 0;
29444 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29445 }
29446
29447 function captureDelta() {
29448 var tbody = document.getElementById('delta-tbody');
29449 if (!tbody) return;
29450 var rows = tbody.querySelectorAll('.delta-row');
29451 for (var i = 0; i < rows.length; i++) {
29452 var r = rows[i];
29453 DELTA.push({
29454 h: r.innerHTML,
29455 cls: r.className,
29456 path: r.getAttribute('data-path') || '',
29457 lang: r.getAttribute('data-language') || '',
29458 status: r.getAttribute('data-status') || '',
29459 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29460 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29461 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29462 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29463 td: parseDeltaNum(r.getAttribute('data-total-delta')),
29464 bcs: r.getAttribute('data-baseline-code') || '',
29465 ccs: r.getAttribute('data-current-code') || '',
29466 cds: r.getAttribute('data-code-delta') || '',
29467 cmds: r.getAttribute('data-comment-delta') || '',
29468 tds: r.getAttribute('data-total-delta') || ''
29469 });
29470 }
29471 tbody.innerHTML = '';
29472 }
29473
29474 function applyDeltaQuery() {
29475 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29476 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29477 if (sortCol) {
29478 var asc = sortOrder === 'asc';
29479 v.sort(function(a, b) {
29480 var va, vb;
29481 if (sortCol === 'path') { va = a.path; vb = b.path; }
29482 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29483 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29484 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29485 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29486 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29487 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29488 else { return 0; }
29489 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29490 return va < vb ? 1 : va > vb ? -1 : 0;
29491 });
29492 }
29493 _deltaView = v;
29494 deltaCurrPage = 1;
29495 renderDeltaPage();
29496 }
29497
29498 function renderDeltaPage() {
29499 var total = _deltaView.length;
29500 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29501 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29502 if (deltaCurrPage < 1) deltaCurrPage = 1;
29503 var start = (deltaCurrPage - 1) * deltaPerPage;
29504 var end = Math.min(start + deltaPerPage, total);
29505 var tbody = document.getElementById('delta-tbody');
29506 if (tbody) {
29507 var html = '';
29508 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29509 tbody.innerHTML = html;
29510 }
29511 var rl = document.getElementById('pg-range-label');
29512 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29513 var btns = document.getElementById('pg-btns');
29514 if (!btns) return;
29515 btns.innerHTML = '';
29516 if (totalPages <= 1) return;
29517 function makeBtn(lbl, pg, active, disabled) {
29518 var b = document.createElement('button');
29519 b.className = 'pg-btn' + (active ? ' active' : '');
29520 b.textContent = lbl; b.disabled = disabled;
29521 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29522 return b;
29523 }
29524 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29525 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29526 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29527 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29528 }
29529
29530 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29531
29532 function filterRows(status, btn) {
29533 activeStatusFilter = status;
29534 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29535 b.classList.remove('active');
29536 });
29537 if (btn) btn.classList.add('active');
29538 applyDeltaQuery();
29539 }
29540
29541 // ── Sorting ──────────────────────────────────────────────────────────────
29542 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29543 sortHeaders.forEach(function(th) {
29544 th.addEventListener('click', function(e) {
29545 if (e.target.classList.contains('col-resize-handle')) return;
29546 var col = th.dataset.sortCol;
29547 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29548 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29549 th.classList.add('sort-' + sortOrder);
29550 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29551 applyDeltaQuery();
29552 });
29553 });
29554
29555 // ── Column resize ─────────────────────────────────────────────────────────
29556 (function() {
29557 var table = document.getElementById('delta-table');
29558 if (!table) return;
29559 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29560 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29561 ths.forEach(function(th, i) {
29562 var handle = th.querySelector('.col-resize-handle');
29563 if (!handle || !cols[i]) return;
29564 handle.addEventListener('mousedown', function(e) {
29565 e.stopPropagation(); e.preventDefault();
29566 // Lock every column to its current rendered px width and size the table
29567 // to the column total. With table-layout:fixed + width:100% the table is
29568 // pinned to the container, so widening one <col> only rebalances the rest
29569 // and the drag looks inert; pinning px widths lets the column actually
29570 // grow while the wrapper (overflow-x:auto) scrolls.
29571 var startTableW = 0;
29572 for (var k = 0; k < ths.length; k++) {
29573 if (!cols[k]) continue;
29574 var w = ths[k].getBoundingClientRect().width;
29575 cols[k].style.width = w + 'px';
29576 startTableW += w;
29577 }
29578 table.style.width = startTableW + 'px';
29579 var startX = e.clientX;
29580 var startW = ths[i].getBoundingClientRect().width;
29581 handle.classList.add('dragging');
29582 function onMove(ev) {
29583 var newW = Math.max(40, startW + ev.clientX - startX);
29584 cols[i].style.width = newW + 'px';
29585 table.style.width = (startTableW + (newW - startW)) + 'px';
29586 }
29587 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29588 document.addEventListener('mousemove', onMove);
29589 document.addEventListener('mouseup', onUp);
29590 });
29591 });
29592 })();
29593
29594 // ── Reset ─────────────────────────────────────────────────────────────────
29595 window.resetDeltaTable = function() {
29596 sortCol = null; sortOrder = 'asc';
29597 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29598 var table = document.getElementById('delta-table');
29599 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29600 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29601 activeStatusFilter = 'all';
29602 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29603 var allBtn = document.querySelector('.tab-btn');
29604 if (allBtn) allBtn.classList.add('active');
29605 applyDeltaQuery();
29606 };
29607
29608 // Compact number formatter (shared by the delta table; charts define their own locally)
29609 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();}
29610 function fmtFull(n){return Number(n).toLocaleString();}
29611
29612 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29613 function fmtFromTo() {
29614 var tbody = document.getElementById('delta-tbody');
29615 if (!tbody) return;
29616 tbody.querySelectorAll('.delta-row').forEach(function(row) {
29617 var status = row.dataset.status || '';
29618 var ft = row.querySelector('.from-to');
29619 if (!ft) return;
29620 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29621 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29622 var strongs = ft.querySelectorAll('strong');
29623 // Apply fmt() to non-absent strong values
29624 strongs.forEach(function(el) {
29625 var n = parseInt(el.textContent, 10);
29626 if (!isNaN(n)) el.textContent = fmtFull(n);
29627 });
29628 // Safety: force dash for genuinely absent sides
29629 if (status === 'added' && bv === 0) {
29630 var bs = ft.querySelector('strong:first-of-type');
29631 if (bs && bs.textContent === '0') {
29632 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29633 }
29634 }
29635 if (status === 'removed' && cv === 0) {
29636 var cs = ft.querySelector('strong:last-of-type');
29637 if (cs && cs.textContent === '0') {
29638 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29639 }
29640 }
29641 });
29642 }
29643 // Initialize: format the server-rendered rows, lift them into the data model
29644 // (which also clears the DOM), then render only the first page.
29645 fmtFromTo();
29646 captureDelta();
29647 applyDeltaQuery();
29648
29649 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29650 (function() {
29651 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29652 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29653 });
29654 var resetBtn = document.getElementById('delta-reset-btn');
29655 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29656 var csvBtn = document.getElementById('delta-csv-btn');
29657 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29658 var xlsBtn = document.getElementById('delta-xls-btn');
29659 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29660 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29661 function sdFetchUri(path) {
29662 return fetch(path).then(function(r){return r.blob();}).then(function(b){
29663 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29664 }).catch(function(){return '';});
29665 }
29666 function sdInlineImgs(html, cb) {
29667 var paths=[], seen={};
29668 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29669 if(!paths.length){cb(html);return;}
29670 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29671 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29672 .catch(function(){cb(html);});
29673 }
29674 function buildFullPageHtml(pdfMode) {
29675 if(pdfMode) document.body.classList.add('pdf-mode');
29676 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29677 renderDeltaPage();
29678 var html = document.documentElement.outerHTML;
29679 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29680 if(pdfMode) document.body.classList.remove('pdf-mode');
29681 return html;
29682 }
29683 var chartsBtn = document.getElementById('delta-charts-btn');
29684 if (chartsBtn) chartsBtn.addEventListener('click', function() {
29685 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29686 sdInlineImgs(buildFullPageHtml(false), function(html) {
29687 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29688 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29689 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29690 btn.disabled=false;btn.innerHTML=orig;
29691 });
29692 });
29693 var pageHtmlBtn = document.getElementById('page-export-html-btn');
29694 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29695 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29696 sdInlineImgs(buildFullPageHtml(false), function(html) {
29697 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29698 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29699 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29700 btn.disabled=false;btn.innerHTML=orig;
29701 });
29702 });
29703 // PDF export — clean document-style report, not a web page screenshot
29704 function buildDeltaPdfHtml() {
29705 var sd=_sd, dr=getDeltaExportRows();
29706 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29707 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+'%';}
29708 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29709 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29710 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29711 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29712 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29713 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29714 function fmtN(n){return Number(n).toLocaleString();}
29715 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29716 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>';}
29717 var lm={};
29718 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;});
29719 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29720 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29721 // The header/footer flow in normal document order (NOT position:fixed).
29722 // A fixed header repeats on every printed page in Chromium and overlaps
29723 // the content beneath it — silently swallowing the first few table rows of
29724 // pages 2+ and clipping the summary cards on page 1. Letting the header
29725 // flow once at the top and relying on the table's <thead> (which Chromium
29726 // repeats per page) keeps every row visible. `.body` keeps a small inset
29727 // so nothing bleeds to the sheet edge.
29728 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29729 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29730 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29731 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29732 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29733 '.ph-brand em{color:#c45c10;font-style:normal;}'+
29734 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29735 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29736 '.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;}'+
29737 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29738 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29739 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29740 '.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;}'+
29741 '.body{padding:12px 18px 0;}'+
29742 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29743 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29744 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29745 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29746 '.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;}'+
29747 '.meta>div{flex:1 1 0;}'+
29748 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29749 '.sec{margin-bottom:10px;}'+
29750 '.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;}'+
29751 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29752 '.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;}'+
29753 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29754 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29755 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29756 '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;}'+
29757 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29758 'tr:nth-child(even) td{background:#faf8f6;}'+
29759 '.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;}'+
29760 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29761 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29762 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29763 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29764 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29765 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29766 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29767 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29768 '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29769 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29770 '.fcc-n{font-size:18px;font-weight:900;}'+
29771 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29772 var fileRows=dchg.map(function(r){
29773 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29774 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29775 '<td style="'+ss+'">'+esc(st)+'</td>'+
29776 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29777 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29778 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29779 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29780 var more='';
29781 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('');
29782 var extraCards='';
29783 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>';}
29784 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>';}
29785 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29786 '<div class="pdf-header">'+
29787 '<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>'+
29788 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29789 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29790 '</div>'+
29791 '<div class="body">'+
29792 '<div class="sec"><p class="sh">Summary Metrics</p>'+
29793 '<div class="msec">'+
29794 '<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>'+
29795 '<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>'+
29796 '<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>'+
29797 '<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>'+
29798 '<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>'+
29799 '<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>'+
29800 extraCards+'</div></div>'+
29801 '<div class="sec"><p class="sh">File Changes</p>'+
29802 '<div class="fcsec">'+
29803 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29804 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29805 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29806 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29807 '</div></div>'+
29808 (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>':'')+
29809 '<div class="sec">'+
29810 '<table><thead>'+
29811 '<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>'+
29812 '<tr><th>File</th><th>Language</th><th>Status</th>'+
29813 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29814 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29815 '</div>'+
29816 '<div class="rfoot">'+
29817 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29818 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29819 '</div>'+
29820 '</body></html>';
29821 }
29822 function doDeltaPdf(btn) {
29823 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29824 }
29825 var pdfBtn = document.getElementById('delta-pdf-btn');
29826 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29827 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29828 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29829 if (location.protocol === 'file:') {
29830 [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'; } });
29831 [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'; } });
29832 }
29833 var ppSel = document.getElementById('per-page-sel');
29834 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29835 var pathLink = document.getElementById('project-path-link');
29836 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29837 })();
29838
29839 // ── Export helpers ────────────────────────────────────────────────────────
29840 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
29841 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29842 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);}
29843 function slocMakeXlsx(fname,sd,dr){
29844 var enc=new TextEncoder();
29845 // CRC-32 table
29846 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;}
29847 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;}
29848 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29849 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29850 // Shared string table
29851 var ss=[],si={};
29852 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29853 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29854 // Worksheet builder — each WS() call gets its own row counter R
29855 function WS(){
29856 var R=0,buf=[];
29857 function cl(c){return String.fromCharCode(65+c);}
29858 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29859 '<v>'+S(v)+'</v></c>';}
29860 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29861 (st?' s="'+st+'"':'')+'>'+
29862 '<v>'+(+v)+'</v></c>';}
29863 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29864 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29865 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29866 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29867 '<sheetFormatPr defaultRowHeight="15"/>'+
29868 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29869 return{sc:sc,nc:nc,row:row,xml:xml};
29870 }
29871 // Language breakdown
29872 var lm={};
29873 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;});
29874 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29875 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29876 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29877 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29878 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29879 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29880 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):'';}
29881 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29882 // Summary sheet
29883 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29884 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29885 r1(s1(0,proj,2));
29886 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29887 r1('');
29888 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29889 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))));
29890 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))));
29891 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))));
29892 r1('');
29893 r1(s1(0,'FILE CHANGES',8));
29894 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29895 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29896 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29897 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29898 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29899 if(langs.length){
29900 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29901 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29902 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)));});
29903 }
29904 r1('');r1(s1(0,'SCAN METADATA',8));
29905 r1(s1(1,_blabel)+s1(2,_clabel));
29906 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29907 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29908 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"/>');
29909 // File Delta sheet
29910 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29911 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));
29912 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)));});
29913 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29914 // Shared strings XML
29915 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29916 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29917 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29918 // XLSX file map
29919 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29920 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>',
29921 '_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>',
29922 '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>',
29923 '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>',
29924 '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>',
29925 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29926 // ZIP packer — STORED (no compression), compatible with all XLSX readers
29927 var zparts=[],zcds=[],zoff=0,znf=0;
29928 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29929 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29930 ].forEach(function(name){
29931 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29932 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]);
29933 var entry=new Uint8Array(lha.length+nb.length+sz);
29934 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29935 zparts.push(entry);
29936 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));
29937 var cde=new Uint8Array(cda.length+nb.length);
29938 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29939 zcds.push(cde);zoff+=entry.length;znf++;
29940 });
29941 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29942 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]);
29943 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29944 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29945 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29946 zout.set(new Uint8Array(ea),zpos);
29947 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29948 var xurl=URL.createObjectURL(xblob);
29949 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29950 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29951 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29952 }
29953 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;');}
29954 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29955 function getExportFilename(ext){return _exportBase+'.'+ext;}
29956
29957 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 }}'};
29958 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;}
29959 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29960 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29961 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29962 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29963 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):'';}
29964 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29965 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)]];}
29966 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29967 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)];});}
29968 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29969 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29970
29971 // ── Chart HTML report ─────────────────────────────────────────────────────
29972 function slocChartReport(fname, sd, dr) {
29973 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29974 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29975 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29976 function fmt(n){return Number(n).toLocaleString();}
29977 function px(n){return Math.round(n);}
29978 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29979 // Language map
29980 var lm={};
29981 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;});
29982 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29983
29984 // Builds onmouse* attrs for interactive tooltip on each SVG element
29985 function barTT(label,val){
29986 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29987 }
29988
29989 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29990 // match the Language Code Delta column height) ────────────
29991 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'}];
29992 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29993 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29994 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29995 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29996 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"/>';}
29997 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29998 c1mets.forEach(function(m,i){
29999 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30000 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30001 var gMax=Math.max(m.b,m.c)*1.15||1;
30002 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30003 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>';
30004 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))+'/>';
30005 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>';
30006 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))+'/>';
30007 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>';
30008 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>';
30009 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>';
30010 });
30011 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>';
30012 c1+='</svg>';
30013
30014 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30015 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'}];
30016 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30017 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30018 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30019 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30020 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30021 mets.forEach(function(m,i){
30022 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30023 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30024 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30025 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>';
30026 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30027 if(bw>=52){
30028 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>';
30029 }else{
30030 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30031 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>';
30032 }
30033 });
30034 c2+='</svg>';
30035
30036 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30037 var c3='';
30038 if(langs.length){
30039 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30040 var C3W=550,c3LW=124,c3FW=52;
30041 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30042 var L3rH=30,C3H=langs.length*L3rH+20;
30043 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30044 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30045 langs.forEach(function(l,i){
30046 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30047 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30048 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30049 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>';
30050 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':''))+'/>';
30051 if(bw>=48){
30052 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>';
30053 }else{
30054 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30055 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>';
30056 }
30057 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>';
30058 });
30059 c3+='</svg>';
30060 }
30061
30062 // ── Chart 4: File Change Donut — centered pie with legend below
30063 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;});
30064 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30065 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30066 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">';
30067 var ang=-Math.PI/2;
30068 segs.forEach(function(s){
30069 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30070 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30071 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30072 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30073 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30074 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)+'%')+'/>';
30075 ang+=sw;
30076 });
30077 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>';
30078 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>';
30079 segs.forEach(function(s,i){
30080 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30081 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30082 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>';
30083 });
30084 c4+='</svg>';
30085
30086 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30087 var ttJs='var tt=document.getElementById("ox-tt");'+
30088 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30089 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30090 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30091 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30092 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30093 'function oxHT(){tt.style.display="none";}';
30094
30095 // body max-width keeps charts from inflating beyond design dimensions on
30096 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30097 // each chart's height blows up proportionally, breaking the one-page layout.
30098 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;}'+
30099 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30100 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30101 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30102 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30103 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30104 'svg{display:block;}'+
30105 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30106 '#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;}'+
30107 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30108 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30109 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30110 '<div id="ox-tt"><\/div>'+
30111 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30112 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30113 '<div class="two-col">'+
30114 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30115 '<div class="leg">'+
30116 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30117 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30118 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30119 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30120 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30121 '<\/div>'+
30122 '<div class="two-col">'+
30123 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30124 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30125 '<\/div>'+
30126 '<script>'+ttJs+'<\/script>'+
30127 '<\/body><\/html>';
30128 slocDownload(html, fname, 'text/html;charset=utf-8;');
30129 }
30130 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30131 window.buildDeltaChartsHtml = function() {
30132 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30133 var sd=_sd;
30134 var projEl=document.querySelector('[data-folder]');
30135 var proj=projEl?projEl.getAttribute('data-folder'):'';
30136 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30137 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30138 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30139 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30140 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";}';
30141 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);}';
30142 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30143 '<div id="ox-tt"><\/div>'+
30144 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30145 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30146 '<div class="two-col">'+
30147 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30148 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30149 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30150 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30151 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30152 '<\/div>'+
30153 '<div class="two-col">'+
30154 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30155 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30156 '<\/div>'+
30157 '<script>'+ttJs+'<\/script>'+
30158 '<\/body><\/html>';
30159 };
30160 // ── Inline delta charts ────────────────────────────────────────────────────
30161 var _icTT=document.getElementById('ic-tt');
30162 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30163 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';};
30164 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30165 window.addEventListener('blur',function(){window.icHT();});
30166 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30167 (function(){
30168 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30169 // charts so every page renders bars/text/grid with the same colours and
30170 // adapts to dark mode (see Design section in CLAUDE.md).
30171 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30172 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30173 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30174 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30175 // washed) so the chart reads with the same weight as /test-metrics.
30176 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30177 var FADE=dark?'#524238':'#e6d0bf';
30178 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30179 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30180 function fmt(n){return Number(n).toLocaleString();}
30181 function px(n){return Math.round(n);}
30182 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30183 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30184 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);});}
30185 var dr=getDeltaExportRows(),sd=_sd,lm={};
30186 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;});
30187 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30188 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30189 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30190 // shares the same grid row, instead of sitting short at the top.
30191 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}];
30192 function drawC1(){
30193 var C1W=600,C1H=188;
30194 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30195 if(host&&card&&host.clientWidth>0){
30196 var avW=host.clientWidth;
30197 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30198 var wantH=availPx*C1W/avW;
30199 if(wantH>C1H)C1H=wantH;
30200 }
30201 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30202 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30203 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"/>';}
30204 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30205 c1mets.forEach(function(m,i){
30206 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30207 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30208 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30209 var gMax=Math.max(m.b,m.c)*1.15||1;
30210 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30211 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>';
30212 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"/>';
30213 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>';
30214 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"/>';
30215 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>';
30216 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>';
30217 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>';
30218 });
30219 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>';
30220 c1+='</svg>';
30221 return c1;
30222 }
30223 var c1=drawC1();
30224 // Chart 2: Delta by Metric
30225 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}];
30226 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30227 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;
30228 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30229 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30230 mets.forEach(function(m,i){
30231 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);
30232 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>';
30233 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30234 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>';}
30235 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>';}
30236 });
30237 c2+='</svg>';
30238 // Chart 3: Language Code Delta
30239 var c3='';
30240 if(langs.length){
30241 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30242 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;
30243 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30244 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30245 langs.forEach(function(l,i){
30246 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);
30247 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>';
30248 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"/>';
30249 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>';}
30250 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>';}
30251 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>';
30252 });
30253 c3+='</svg>';
30254 }
30255 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30256 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30257 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;});
30258 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30259 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30260 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);
30261 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;
30262 if(segs.length===1){
30263 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30264 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+'"/>';
30265 } else {
30266 // Give every visible slice a small minimum sweep, taken from the largest
30267 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30268 // arc whose start and end points coincide, so SVG renders nothing (blank).
30269 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30270 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30271 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30272 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30273 segs.forEach(function(s,si){
30274 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30275 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);
30276 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);
30277 var pct=Math.round(s.v/tot*100);
30278 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"/>';
30279 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>';}
30280 ang+=sw;
30281 });
30282 }
30283 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30284 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30285 segs.forEach(function(s,i){
30286 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30287 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30288 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30289 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30290 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30291 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>';
30292 c4+='</g>';
30293 });
30294 c4+='</svg>';
30295 // Inject the fixed-height siblings first so the grid row settles to the (taller)
30296 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30297 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30298 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);}
30299 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30300 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30301 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30302
30303 // Compare Timeline chart (Baseline vs Current, 2 points)
30304 (function() {
30305 var activeCmpMetric='code';
30306 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30307 function renderCmpTL(metric, targetSvg, targetH) {
30308 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30309 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30310 svg.setAttribute('height',H);
30311 var pad={l:62,r:20,t:32,b:72};
30312 var dark=document.body.classList.contains('dark-theme');
30313 var cmpPts=[
30314 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30315 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30316 ];
30317 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30318 var valid=pts.filter(function(v){return v!=null;});
30319 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;}
30320 var minV=0,maxV=Math.max.apply(null,valid);
30321 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30322 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30323 var cx0=pad.l,cx1=pad.l+plotW;
30324 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30325 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30326 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30327 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30328 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30329 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();}
30330 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30331 var parts=[];
30332 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30333 for(var gi=0;gi<5;gi++){
30334 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30335 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30336 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30337 }
30338 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+'"/>');
30339 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"/>');
30340 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30341 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30342 dotPts.forEach(function(pt){
30343 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>');
30344 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"/>');
30345 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>');
30346 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>');
30347 });
30348 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30349 svg.setAttribute('viewBox','0 0 '+W+' '+H);
30350 svg.innerHTML=parts.join('');
30351 // Hover: crosshair + tooltip (matches multi-scan timeline)
30352 var cmpTT=document.getElementById('ic-tt');
30353 svg.onmousemove=function(e){
30354 var rect=svg.getBoundingClientRect();
30355 var scaleX=W/rect.width;
30356 var mouseX=(e.clientX-rect.left)*scaleX;
30357 var nearest=-1,minDist=Infinity;
30358 var cxArr=[cx0,cx1];
30359 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;}}
30360 if(nearest<0)return;
30361 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30362 var xhair=svg.querySelector('.cmp-xhair');
30363 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30364 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"/>';
30365 if(!cmpTT)return;
30366 var clbl=cmpPts[nearest].label;
30367 var scanLbl=nearest===0?'Baseline':'Current';
30368 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>';
30369 var bx=rect.left+(nc/W*rect.width)+18;
30370 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30371 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30372 };
30373 svg.onmouseleave=function(){
30374 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30375 if(cmpTT)cmpTT.style.display='none';
30376 };
30377 }
30378 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30379 btn.addEventListener('click',function(){
30380 activeCmpMetric=this.dataset.cmpMetric;
30381 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30382 this.classList.add('active');
30383 renderCmpTL(activeCmpMetric);
30384 });
30385 });
30386 var ttgl=document.getElementById('theme-toggle');
30387 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30388 if(typeof ResizeObserver!=='undefined'){
30389 var cmpSvg=document.getElementById('cmp-tl-svg');
30390 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30391 }
30392 // Expose the timeline renderer + current metric so the Full View modal can
30393 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30394 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30395 window.__sdGetMetric=function(){return activeCmpMetric;};
30396 renderCmpTL(activeCmpMetric);
30397 })();
30398
30399 // HTML legend hover -> highlight matching SVG bars within the SAME card only
30400 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30401 var metric=leg.getAttribute('data-highlight');
30402 var parentCard=leg.closest('.ic-card');
30403 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30404 if(!chartEl)return;
30405 leg.addEventListener('mouseenter',function(){
30406 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30407 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';}
30408 else{x.style.opacity='0.28';}
30409 });
30410 });
30411 leg.addEventListener('mouseleave',function(){
30412 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30413 });
30414 });
30415
30416 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30417 (function(){
30418 var ov=document.getElementById('ic-svg-modal-ov');
30419 var body=document.getElementById('ic-svg-modal-body');
30420 var ttl=document.getElementById('ic-svg-modal-title');
30421 var closeBtn=document.getElementById('ic-svg-modal-close');
30422 if(!ov||!body)return;
30423 function close(){
30424 ov.classList.remove('open');body.innerHTML='';
30425 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30426 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30427 }
30428 function open(srcId,title){
30429 var src=document.getElementById(srcId);if(!src)return;
30430 if(ttl)ttl.textContent=title||'';
30431 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30432 // snapshot stretches and loses interactivity. Re-render it live into the modal at
30433 // full size instead — keeps proportions, animation, crosshair, tooltip and the
30434 // metric tabs working exactly like the inline chart.
30435 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30436 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30437 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30438 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('');
30439 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>';
30440 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30441 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30442 ov.classList.add('open');
30443 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30444 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;}
30445 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30446 b.addEventListener('click',function(){
30447 if(!window.__sdFvTL)return;
30448 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30449 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30450 this.classList.add('active');
30451 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30452 });
30453 });
30454 return;
30455 }
30456 var card=src.closest('.ic-card');
30457 var legHtml='';
30458 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30459 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30460 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;}
30461 body.innerHTML=legHtml+inner;
30462 var svg=body.querySelector('svg');
30463 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30464 addTT(body);
30465 ov.classList.add('open');
30466 }
30467 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30468 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30469 });
30470 if(closeBtn)closeBtn.addEventListener('click',close);
30471 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30472 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30473 })();
30474
30475 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30476 })();
30477 </script>
30478 {{ toast_assets|safe }}
30479 <script nonce="{{ csp_nonce }}">
30480 (function(){
30481 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'}];
30482 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);});}
30483 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30484 function init(){
30485 var btn=document.getElementById('settings-btn');if(!btn)return;
30486 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30487 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>';
30488 document.body.appendChild(m);
30489 var g=document.getElementById('scheme-grid');
30490 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);});
30491 var cl=document.getElementById('settings-close');
30492 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);});})();
30493 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');});
30494 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30495 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30496 }
30497 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30498 }());
30499 </script>
30500 <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]';
30501 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;}
30502 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>
30503</body>
30504</html>
30505"##,
30506 ext = "html"
30507)]
30508#[allow(clippy::struct_excessive_bools)]
30510struct CompareTemplate {
30511 loading_overlay: String,
30513 version: &'static str,
30514 project_label: String,
30515 baseline_git_commit: String,
30516 current_git_commit: String,
30517 baseline_run_id: String,
30518 current_run_id: String,
30519 baseline_run_id_short: String,
30520 current_run_id_short: String,
30521 baseline_timestamp: String,
30522 baseline_timestamp_utc_ms: i64,
30523 current_timestamp: String,
30524 current_timestamp_utc_ms: i64,
30525 project_path: String,
30526 baseline_code: u64,
30527 current_code: u64,
30528 code_lines_delta_str: String,
30529 code_lines_delta_class: String,
30530 baseline_files: u64,
30531 current_files: u64,
30532 files_analyzed_delta_str: String,
30533 files_analyzed_delta_class: String,
30534 baseline_comments: u64,
30535 current_comments: u64,
30536 comment_lines_delta_str: String,
30537 comment_lines_delta_class: String,
30538 baseline_code_fmt: String,
30539 current_code_fmt: String,
30540 baseline_files_fmt: String,
30541 current_files_fmt: String,
30542 baseline_comments_fmt: String,
30543 current_comments_fmt: String,
30544 code_lines_pct_str: String,
30545 files_analyzed_pct_str: String,
30546 comment_lines_pct_str: String,
30547 code_lines_added: i64,
30548 code_lines_removed: i64,
30549 code_lines_modified: i64,
30551 code_lines_unmodified: i64,
30553 new_scope: bool,
30555 churn_rate_str: String,
30556 churn_rate_class: String,
30557 scope_flag: bool,
30558 files_added: usize,
30559 files_removed: usize,
30560 files_modified: usize,
30561 files_unchanged: usize,
30562 file_rows: Vec<CompareFileDeltaRow>,
30563 baseline_git_author: Option<String>,
30564 current_git_author: Option<String>,
30565 baseline_git_branch: String,
30566 current_git_branch: String,
30567 baseline_git_tags: Option<String>,
30568 current_git_tags: Option<String>,
30569 baseline_git_commit_date: Option<String>,
30570 current_git_commit_date: Option<String>,
30571 project_name: String,
30572 submodule_options: Vec<String>,
30574 has_any_submodule_data: bool,
30576 active_submodule: Option<String>,
30578 super_scope_active: bool,
30580 csp_nonce: String,
30581 toast_assets: String,
30583 coverage_delta_card: String,
30585 baseline_test_count: u64,
30586 current_test_count: u64,
30587 baseline_coverage_pct: Option<f64>,
30588 current_coverage_pct: Option<f64>,
30589}
30590
30591#[derive(Template)]
30594#[template(
30595 source = r##"
30596<!doctype html>
30597<html lang="en">
30598<head>
30599 <meta charset="utf-8">
30600 <meta name="viewport" content="width=device-width, initial-scale=1">
30601 <title>OxideSLOC | Sign In</title>
30602 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30603 <style nonce="{{ csp_nonce }}">
30604 :root {
30605 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30606 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30607 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30608 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30609 }
30610 *{box-sizing:border-box;}
30611 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);}
30612 .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);}
30613 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30614 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30615 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30616 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30617 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30618 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30619 .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;}
30620 @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));}}
30621 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30622 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30623 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30624 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30625 .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;}
30626 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30627 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;}
30628 input[type=password]:focus{border-color:var(--oxide);}
30629 .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;}
30630 .btn:hover{opacity:.88;}
30631 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30632 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30633 </style>
30634</head>
30635<body>
30636 <div class="background-watermarks" aria-hidden="true">
30637 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30638 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30639 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30640 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30641 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30642 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30643 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30644 </div>
30645 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30646<nav class="top-nav">
30647 <a class="brand" href="/">
30648 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30649 <span class="brand-title">OxideSLOC</span>
30650 </a>
30651</nav>
30652<main class="page">
30653 <div class="card">
30654 <h1>Sign In</h1>
30655 <p class="subtitle">Enter the API key printed when the server started.</p>
30656 {% if has_error %}
30657 <div class="error">Incorrect API key — please try again.</div>
30658 {% endif %}
30659 <form method="POST" action="/auth/login">
30660 <input type="hidden" name="next" value="{{ next_url|e }}">
30661 <label for="key">API Key</label>
30662 <input id="key" type="password" name="key" autocomplete="current-password"
30663 placeholder="Paste your API key here" autofocus>
30664 <button type="submit" class="btn">Sign In</button>
30665 </form>
30666 <p class="hint">
30667 The API key was printed in the terminal when the server started.<br>
30668 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30669 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30670 </p>
30671 </div>
30672</main>
30673<script nonce="{{ csp_nonce }}">
30674(function() {
30675 (function randomizeWatermarks() {
30676 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30677 if (!wms.length) return;
30678 var placed = [];
30679 function tooClose(top, left) {
30680 for (var i = 0; i < placed.length; i++) {
30681 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30682 if (dt < 16 && dl < 12) return true;
30683 }
30684 return false;
30685 }
30686 function pick(leftBand) {
30687 for (var attempt = 0; attempt < 50; attempt++) {
30688 var top = Math.random() * 88 + 2;
30689 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30690 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30691 }
30692 var top = Math.random() * 88 + 2;
30693 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30694 placed.push([top, left]); return [top, left];
30695 }
30696 var half = Math.floor(wms.length / 2);
30697 wms.forEach(function (img, i) {
30698 var pos = pick(i < half);
30699 var size = Math.floor(Math.random() * 100 + 120);
30700 var rot = (Math.random() * 360).toFixed(1);
30701 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30702 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;
30703 });
30704 })();
30705 (function spawnCodeParticles() {
30706 var container = document.getElementById('code-particles');
30707 if (!container) return;
30708 var snippets = [
30709 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30710 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30711 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30712 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30713 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30714 ];
30715 var count = 38;
30716 for (var i = 0; i < count; i++) {
30717 (function(idx) {
30718 var el = document.createElement('span');
30719 el.className = 'code-particle';
30720 el.textContent = snippets[idx % snippets.length];
30721 var left = Math.random() * 94 + 2;
30722 var top = Math.random() * 88 + 6;
30723 var dur = (Math.random() * 10 + 9).toFixed(1);
30724 var delay = (Math.random() * 18).toFixed(1);
30725 var rot = (Math.random() * 26 - 13).toFixed(1);
30726 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30727 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30728 container.appendChild(el);
30729 })(i);
30730 }
30731 })();
30732})();
30733</script>
30734</body>
30735</html>
30736"##,
30737 ext = "html"
30738)]
30739pub(crate) struct LoginTemplate {
30740 pub(crate) csp_nonce: String,
30741 pub(crate) has_error: bool,
30742 pub(crate) next_url: String,
30743 pub(crate) lockout_threshold: u32,
30744}
30745
30746#[derive(Template)]
30749#[template(
30750 source = r##"
30751<!doctype html>
30752<html lang="en">
30753<head>
30754 <meta charset="utf-8">
30755 <meta name="viewport" content="width=device-width, initial-scale=1">
30756 <title>OxideSLOC — REST API Reference</title>
30757 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30758 <style nonce="{{ csp_nonce }}">
30759 :root {
30760 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30761 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30762 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30763 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30764 --success:#16a34a;
30765 }
30766 body.dark-theme {
30767 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30768 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30769 }
30770 *{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;}
30771 .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);}
30772 .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;}
30773 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30774 .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));}
30775 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30776 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30777 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30778 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30779 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30780 @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; } }
30781 .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;}
30782 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30783 .nav-pill.active{background:rgba(255,255,255,0.22);}
30784 .nav-dropdown{position:relative;display:inline-flex;}
30785 .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;}
30786 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30787 .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;}
30788 .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;}
30789 .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);}
30790 .nav-dropdown-menu a:last-child{border-bottom:none;}
30791 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30792 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30793 .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;}
30794 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30795 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30796 .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;}
30797 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30798 .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);}
30799 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30800 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30801 .settings-modal-body{padding:14px 16px 16px;}
30802 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30803 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30804 .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;}
30805 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30806 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30807 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30808 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30809 .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;}
30810 .tz-select:focus{border-color:var(--oxide);}
30811 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30812 .page-header{margin-bottom:28px;}
30813 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30814 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30815 .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;}
30816 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30817 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30818 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30819 .callout strong{font-weight:800;}
30820 .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;}
30821 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30822 .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;}
30823 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30824 .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;}
30825 body.dark-theme .base-url-value{color:var(--accent);}
30826 .section{margin-bottom:36px;}
30827 .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);}
30828 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30829 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30830 .ep-header:hover{background:var(--surface-2);}
30831 .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;}
30832 .method.get{background:#dcfce7;color:#166534;}
30833 .method.post{background:#dbeafe;color:#1e40af;}
30834 .method.delete{background:#fee2e2;color:#991b1b;}
30835 body.dark-theme .method.get{background:#14532d;color:#86efac;}
30836 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30837 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30838 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30839 .ep-path .param{color:var(--oxide-2);}
30840 body.dark-theme .ep-path .param{color:var(--oxide);}
30841 .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;}
30842 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30843 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30844 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30845 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30846 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30847 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30848 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30849 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30850 .ep-card.open .chevron{transform:rotate(180deg);}
30851 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30852 .ep-card.open .ep-body{display:block;}
30853 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30854 .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;}
30855 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30856 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30857 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30858 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30859 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);}
30860 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30861 table.params tr:last-child td{border-bottom:none;}
30862 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30863 .pt-type{color:var(--muted-2);font-size:12px;}
30864 .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;}
30865 .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;}
30866 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30867 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30868 details.schema{margin-bottom:14px;}
30869 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;}
30870 details.schema summary:hover{color:var(--text);}
30871 .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;}
30872 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30873 .curl-wrap{position:relative;}
30874 .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;}
30875 .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;}
30876 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30877 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30878 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30879 .webhook-note a{color:var(--accent-2);text-decoration:none;}
30880 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30881 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30882 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30883 .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;}
30884 @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));}}
30885 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30886 .site-footer a{color:var(--muted);}
30887 </style>
30888</head>
30889<body>
30890 <div class="background-watermarks" aria-hidden="true">
30891 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30892 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30893 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30894 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30895 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30896 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30897 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30898 </div>
30899 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30900 <div class="top-nav">
30901 <div class="top-nav-inner">
30902 <a class="brand" href="/">
30903 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30904 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30905 </a>
30906 <div class="nav-right">
30907 <a class="nav-pill" href="/">Home</a>
30908 <div class="nav-dropdown">
30909 <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>
30910 <div class="nav-dropdown-menu">
30911 <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>
30912 </div>
30913 </div>
30914 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30915 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30916 <div class="nav-dropdown">
30917 <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>
30918 <div class="nav-dropdown-menu">
30919 <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>
30920 </div>
30921 </div>
30922 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30923 <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>
30924 </button>
30925 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30926 <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>
30927 <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>
30928 </button>
30929 </div>
30930 </div>
30931 </div>
30932
30933 <div class="page">
30934 <div class="page-header">
30935 <h1 class="page-title">REST API Reference</h1>
30936 <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>
30937 </div>
30938
30939 {% if has_api_key %}
30940 <div class="callout key-set">
30941 <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>
30942 <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>
30943 </div>
30944 {% else %}
30945 <div class="callout no-key">
30946 <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>
30947 <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>
30948 </div>
30949 {% endif %}
30950
30951 <div class="base-url-bar">
30952 <span class="base-url-label">Base URL</span>
30953 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30954 </div>
30955
30956 <!-- Health -->
30957 <div class="section">
30958 <h2 class="section-title">Health & Status</h2>
30959 <div class="ep-card">
30960 <div class="ep-header">
30961 <span class="method get">GET</span>
30962 <span class="ep-path">/healthz</span>
30963 <span class="auth-badge public">Public</span>
30964 <span class="ep-desc">Server liveness check</span>
30965 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30966 </div>
30967 <div class="ep-body">
30968 <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>
30969 <p class="params-heading">Response</p>
30970 <div class="schema-block">200 OK
30971Content-Type: text/plain
30972
30973ok</div>
30974 <p class="curl-heading">Example</p>
30975 <div class="curl-wrap">
30976 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30977 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30978 </div>
30979 </div>
30980 </div>
30981 </div>
30982
30983 <!-- Badges -->
30984 <div class="section">
30985 <h2 class="section-title">Badges</h2>
30986 <div class="ep-card">
30987 <div class="ep-header">
30988 <span class="method get">GET</span>
30989 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30990 <span class="auth-badge public">Public</span>
30991 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30992 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30993 </div>
30994 <div class="ep-body">
30995 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30996 <p class="params-heading">Path Parameters</p>
30997 <table class="params">
30998 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30999 <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>
31000 </table>
31001 <p class="curl-heading">Example</p>
31002 <div class="curl-wrap">
31003 <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>
31004 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31005 </div>
31006 </div>
31007 </div>
31008 </div>
31009
31010 <!-- Metrics -->
31011 <div class="section">
31012 <h2 class="section-title">Metrics</h2>
31013
31014 <div class="ep-card">
31015 <div class="ep-header">
31016 <span class="method get">GET</span>
31017 <span class="ep-path">/api/metrics/latest</span>
31018 <span class="auth-badge protected">Protected</span>
31019 <span class="ep-desc">Latest scan metrics (JSON)</span>
31020 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31021 </div>
31022 <div class="ep-body">
31023 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31024 <details class="schema"><summary>Response schema</summary>
31025<div class="schema-block">{
31026 "run_id": string, // UUID
31027 "timestamp": string, // ISO-8601 UTC
31028 "project": string, // scanned root path
31029 "summary": {
31030 "files_analyzed": number,
31031 "files_skipped": number,
31032 "code_lines": number,
31033 "comment_lines": number,
31034 "blank_lines": number,
31035 "total_physical_lines": number,
31036 "functions": number,
31037 "classes": number,
31038 "variables": number,
31039 "imports": number
31040 },
31041 "languages": [
31042 { "name": string, "files": number, "code_lines": number,
31043 "comment_lines": number, "blank_lines": number,
31044 "functions": number, "classes": number,
31045 "variables": number, "imports": number }
31046 ]
31047}</div></details>
31048 <p class="curl-heading">Example</p>
31049 <div class="curl-wrap">
31050 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31051 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31052 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31053 </div>
31054 </div>
31055 </div>
31056
31057 <div class="ep-card">
31058 <div class="ep-header">
31059 <span class="method get">GET</span>
31060 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31061 <span class="auth-badge protected">Protected</span>
31062 <span class="ep-desc">Metrics for a specific run</span>
31063 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31064 </div>
31065 <div class="ep-body">
31066 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31067 <p class="params-heading">Path Parameters</p>
31068 <table class="params">
31069 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31070 <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>
31071 </table>
31072 <p class="curl-heading">Example</p>
31073 <div class="curl-wrap">
31074 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31075 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31076 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31077 </div>
31078 </div>
31079 </div>
31080
31081 <div class="ep-card">
31082 <div class="ep-header">
31083 <span class="method get">GET</span>
31084 <span class="ep-path">/api/metrics/history</span>
31085 <span class="auth-badge protected">Protected</span>
31086 <span class="ep-desc">Paginated scan history</span>
31087 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31088 </div>
31089 <div class="ep-body">
31090 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31091 <p class="params-heading">Query Parameters</p>
31092 <table class="params">
31093 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31094 <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>
31095 <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>
31096 </table>
31097 <details class="schema"><summary>Response schema</summary>
31098<div class="schema-block">[{
31099 "run_id": string,
31100 "timestamp": string, // ISO-8601 UTC
31101 "commit": string | null,
31102 "branch": string | null,
31103 "tags": string[],
31104 "code_lines": number,
31105 "comment_lines": number,
31106 "blank_lines": number,
31107 "physical_lines": number,
31108 "files_analyzed": number,
31109 "project_label": string,
31110 "html_url": string | null
31111}]</div></details>
31112 <p class="curl-heading">Example</p>
31113 <div class="curl-wrap">
31114 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31115 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31116 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31117 </div>
31118 </div>
31119 </div>
31120
31121 <div class="ep-card">
31122 <div class="ep-header">
31123 <span class="method get">GET</span>
31124 <span class="ep-path">/api/project-history</span>
31125 <span class="auth-badge protected">Protected</span>
31126 <span class="ep-desc">Project-level scan summary</span>
31127 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31128 </div>
31129 <div class="ep-body">
31130 <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>
31131 <p class="params-heading">Query Parameters</p>
31132 <table class="params">
31133 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31134 <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>
31135 </table>
31136 <details class="schema"><summary>Response schema</summary>
31137<div class="schema-block">{
31138 "scan_count": number,
31139 "last_scan_id": string | null,
31140 "last_scan_timestamp": string | null, // ISO-8601
31141 "last_scan_code_lines": number | null,
31142 "last_git_branch": string | null,
31143 "last_git_commit": string | null
31144}</div></details>
31145 <p class="curl-heading">Example</p>
31146 <div class="curl-wrap">
31147 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31148 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31149 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31150 </div>
31151 </div>
31152 </div>
31153
31154 <div class="ep-card">
31155 <div class="ep-header">
31156 <span class="method get">GET</span>
31157 <span class="ep-path">/api/metrics/submodules</span>
31158 <span class="auth-badge protected">Protected</span>
31159 <span class="ep-desc">List known git submodules across scans</span>
31160 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31161 </div>
31162 <div class="ep-body">
31163 <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>
31164 <p class="params-heading">Query Parameters</p>
31165 <table class="params">
31166 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31167 <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>
31168 </table>
31169 <details class="schema"><summary>Response schema</summary>
31170<div class="schema-block">[{
31171 "name": string, // submodule name
31172 "relative_path": string // path relative to the project root
31173}]</div></details>
31174 <p class="curl-heading">Example</p>
31175 <div class="curl-wrap">
31176 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31177 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31178 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31179 </div>
31180 </div>
31181 </div>
31182 </div>
31183
31184 <!-- Async Run Status -->
31185 <div class="section">
31186 <h2 class="section-title">Async Run Status</h2>
31187
31188 <div class="ep-card">
31189 <div class="ep-header">
31190 <span class="method get">GET</span>
31191 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31192 <span class="auth-badge protected">Protected</span>
31193 <span class="ep-desc">Poll scan completion</span>
31194 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31195 </div>
31196 <div class="ep-body">
31197 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31198 <details class="schema"><summary>Response schema</summary>
31199<div class="schema-block">// Running
31200{ "state": "running", "elapsed_secs": number }
31201
31202// Complete
31203{ "state": "complete", "run_id": string }
31204
31205// Failed
31206{ "state": "failed", "message": string }</div></details>
31207 <p class="curl-heading">Example</p>
31208 <div class="curl-wrap">
31209 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31210 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31211 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31212 </div>
31213 </div>
31214 </div>
31215
31216 <div class="ep-card">
31217 <div class="ep-header">
31218 <span class="method get">GET</span>
31219 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31220 <span class="auth-badge protected">Protected</span>
31221 <span class="ep-desc">Poll PDF generation readiness</span>
31222 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31223 </div>
31224 <div class="ep-body">
31225 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31226 <details class="schema"><summary>Response schema</summary>
31227<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31228 <p class="curl-heading">Example</p>
31229 <div class="curl-wrap">
31230 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31231 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31232 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31233 </div>
31234 </div>
31235 </div>
31236
31237 <div class="ep-card">
31238 <div class="ep-header">
31239 <span class="method post">POST</span>
31240 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31241 <span class="auth-badge protected">Protected</span>
31242 <span class="ep-desc">Cancel a running scan</span>
31243 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31244 </div>
31245 <div class="ep-body">
31246 <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>
31247 <p class="curl-heading">Example</p>
31248 <div class="curl-wrap">
31249 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31250 -H "Authorization: Bearer $SLOC_API_KEY" \
31251 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31252 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31253 </div>
31254 </div>
31255 </div>
31256 </div>
31257
31258 <!-- Run Management -->
31259 <div class="section">
31260 <h2 class="section-title">Run Management</h2>
31261
31262 <div class="ep-card">
31263 <div class="ep-header">
31264 <span class="method get">GET</span>
31265 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31266 <span class="auth-badge protected">Protected</span>
31267 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31268 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31269 </div>
31270 <div class="ep-body">
31271 <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>
31272 <p class="params-heading">Path Parameters</p>
31273 <table class="params">
31274 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31275 <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>
31276 </table>
31277 <details class="schema"><summary>Response</summary>
31278<div class="schema-block">200 OK — Content-Type: application/zip
31279Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
31280
31281404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
31282 <p class="curl-heading">Example</p>
31283 <div class="curl-wrap">
31284 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31285 -o run.zip \
31286 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
31287 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31288 </div>
31289 </div>
31290 </div>
31291
31292 <div class="ep-card">
31293 <div class="ep-header">
31294 <span class="method delete">DELETE</span>
31295 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31296 <span class="auth-badge protected">Protected</span>
31297 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31298 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31299 </div>
31300 <div class="ep-body">
31301 <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>
31302 <p class="params-heading">Path Parameters</p>
31303 <table class="params">
31304 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31305 <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>
31306 </table>
31307 <details class="schema"><summary>Response</summary>
31308<div class="schema-block">204 No Content — run successfully deleted
31309
31310500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
31311 <p class="curl-heading">Example</p>
31312 <div class="curl-wrap">
31313 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31314 -H "Authorization: Bearer $SLOC_API_KEY" \
31315 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
31316 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31317 </div>
31318 </div>
31319 </div>
31320
31321 <div class="ep-card">
31322 <div class="ep-header">
31323 <span class="method post">POST</span>
31324 <span class="ep-path">/api/runs/cleanup</span>
31325 <span class="auth-badge protected">Protected</span>
31326 <span class="ep-desc">Bulk delete runs older than N days</span>
31327 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31328 </div>
31329 <div class="ep-body">
31330 <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>
31331 <p class="params-heading">Request Body (application/json)</p>
31332 <table class="params">
31333 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31334 <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>
31335 </table>
31336 <details class="schema"><summary>Response schema</summary>
31337<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
31338 <p class="curl-heading">Example — delete runs older than 60 days</p>
31339 <div class="curl-wrap">
31340 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31341 -H "Authorization: Bearer $SLOC_API_KEY" \
31342 -H "Content-Type: application/json" \
31343 -d '{"older_than_days":60}' \
31344 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31345 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31346 </div>
31347 </div>
31348 </div>
31349 </div>
31350
31351 <!-- Retention Policy -->
31352 <div class="section">
31353 <h2 class="section-title">Retention Policy</h2>
31354
31355 <div class="ep-card">
31356 <div class="ep-header">
31357 <span class="method get">GET</span>
31358 <span class="ep-path">/api/cleanup-policy</span>
31359 <span class="auth-badge protected">Protected</span>
31360 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31361 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31362 </div>
31363 <div class="ep-body">
31364 <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>
31365 <details class="schema"><summary>Response schema</summary>
31366<div class="schema-block">{
31367 "policy": {
31368 "enabled": boolean,
31369 "max_age_days": number | null, // delete runs older than N days
31370 "max_run_count": number | null, // keep only the N most recent runs
31371 "interval_hours": number // hours between background passes
31372 } | null,
31373 "last_run_at": string | null, // ISO-8601 UTC timestamp
31374 "last_run_deleted": number | null // runs deleted in last pass
31375}</div></details>
31376 <p class="curl-heading">Example</p>
31377 <div class="curl-wrap">
31378 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31379 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31380 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31381 </div>
31382 </div>
31383 </div>
31384
31385 <div class="ep-card">
31386 <div class="ep-header">
31387 <span class="method post">POST</span>
31388 <span class="ep-path">/api/cleanup-policy</span>
31389 <span class="auth-badge protected">Protected</span>
31390 <span class="ep-desc">Save or update the retention policy</span>
31391 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31392 </div>
31393 <div class="ep-body">
31394 <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>
31395 <p class="params-heading">Request Body (application/json)</p>
31396 <table class="params">
31397 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31398 <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>
31399 <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>
31400 <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>
31401 <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>
31402 </table>
31403 <details class="schema"><summary>Response</summary>
31404<div class="schema-block">204 No Content — policy saved and task (re)started
31405
31406500 Internal Server Error — { "error": string }</div></details>
31407 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31408 <div class="curl-wrap">
31409 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31410 -H "Authorization: Bearer $SLOC_API_KEY" \
31411 -H "Content-Type: application/json" \
31412 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31413 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31414 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31415 </div>
31416 </div>
31417 </div>
31418
31419 <div class="ep-card">
31420 <div class="ep-header">
31421 <span class="method post">POST</span>
31422 <span class="ep-path">/api/cleanup-policy/run-now</span>
31423 <span class="auth-badge protected">Protected</span>
31424 <span class="ep-desc">Trigger an immediate cleanup pass</span>
31425 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31426 </div>
31427 <div class="ep-body">
31428 <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>
31429 <details class="schema"><summary>Response schema</summary>
31430<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
31431 <p class="curl-heading">Example</p>
31432 <div class="curl-wrap">
31433 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31434 -H "Authorization: Bearer $SLOC_API_KEY" \
31435 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31436 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31437 </div>
31438 </div>
31439 </div>
31440
31441 <div class="ep-card">
31442 <div class="ep-header">
31443 <span class="method delete">DELETE</span>
31444 <span class="ep-path">/api/cleanup-policy</span>
31445 <span class="auth-badge protected">Protected</span>
31446 <span class="ep-desc">Remove the retention policy and stop the background task</span>
31447 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31448 </div>
31449 <div class="ep-body">
31450 <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>
31451 <details class="schema"><summary>Response</summary>
31452<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31453 <p class="curl-heading">Example</p>
31454 <div class="curl-wrap">
31455 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31456 -H "Authorization: Bearer $SLOC_API_KEY" \
31457 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31458 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31459 </div>
31460 </div>
31461 </div>
31462 </div>
31463
31464 <!-- Scan Profiles -->
31465 <div class="section">
31466 <h2 class="section-title">Scan Profiles</h2>
31467
31468 <div class="ep-card">
31469 <div class="ep-header">
31470 <span class="method get">GET</span>
31471 <span class="ep-path">/api/scan-profiles</span>
31472 <span class="auth-badge protected">Protected</span>
31473 <span class="ep-desc">List saved scan profiles</span>
31474 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31475 </div>
31476 <div class="ep-body">
31477 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31478 <details class="schema"><summary>Response schema</summary>
31479<div class="schema-block">{
31480 "profiles": [{
31481 "id": string, // UUID
31482 "name": string,
31483 "created_at": string, // ISO-8601
31484 "params": object
31485 }]
31486}</div></details>
31487 <p class="curl-heading">Example</p>
31488 <div class="curl-wrap">
31489 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31490 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31491 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31492 </div>
31493 </div>
31494 </div>
31495
31496 <div class="ep-card">
31497 <div class="ep-header">
31498 <span class="method post">POST</span>
31499 <span class="ep-path">/api/scan-profiles</span>
31500 <span class="auth-badge protected">Protected</span>
31501 <span class="ep-desc">Save a scan profile</span>
31502 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31503 </div>
31504 <div class="ep-body">
31505 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31506 <p class="params-heading">Request Body (application/json)</p>
31507 <table class="params">
31508 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31509 <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>
31510 <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>
31511 </table>
31512 <details class="schema"><summary>Response schema</summary>
31513<div class="schema-block">{ "ok": true }</div></details>
31514 <p class="curl-heading">Example</p>
31515 <div class="curl-wrap">
31516 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31517 -H "Authorization: Bearer $SLOC_API_KEY" \
31518 -H "Content-Type: application/json" \
31519 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31520 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31521 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31522 </div>
31523 </div>
31524 </div>
31525
31526 <div class="ep-card">
31527 <div class="ep-header">
31528 <span class="method delete">DELETE</span>
31529 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31530 <span class="auth-badge protected">Protected</span>
31531 <span class="ep-desc">Delete a scan profile</span>
31532 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31533 </div>
31534 <div class="ep-body">
31535 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31536 <p class="params-heading">Path Parameters</p>
31537 <table class="params">
31538 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31539 <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>
31540 </table>
31541 <details class="schema"><summary>Response schema</summary>
31542<div class="schema-block">{ "ok": true }</div></details>
31543 <p class="curl-heading">Example</p>
31544 <div class="curl-wrap">
31545 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31546 -H "Authorization: Bearer $SLOC_API_KEY" \
31547 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31548 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31549 </div>
31550 </div>
31551 </div>
31552 </div>
31553
31554 <!-- Scheduled Scans -->
31555 <div class="section">
31556 <h2 class="section-title">Scheduled Scans</h2>
31557
31558 <div class="ep-card">
31559 <div class="ep-header">
31560 <span class="method get">GET</span>
31561 <span class="ep-path">/api/schedules</span>
31562 <span class="auth-badge protected">Protected</span>
31563 <span class="ep-desc">List configured schedules</span>
31564 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31565 </div>
31566 <div class="ep-body">
31567 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31568 <p class="curl-heading">Example</p>
31569 <div class="curl-wrap">
31570 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31571 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31572 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31573 </div>
31574 </div>
31575 </div>
31576
31577 <div class="ep-card">
31578 <div class="ep-header">
31579 <span class="method post">POST</span>
31580 <span class="ep-path">/api/schedules</span>
31581 <span class="auth-badge protected">Protected</span>
31582 <span class="ep-desc">Create a schedule</span>
31583 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31584 </div>
31585 <div class="ep-body">
31586 <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>
31587 <p class="curl-heading">Example</p>
31588 <div class="curl-wrap">
31589 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31590 -H "Authorization: Bearer $SLOC_API_KEY" \
31591 -H "Content-Type: application/json" \
31592 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31593 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31594 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31595 </div>
31596 </div>
31597 </div>
31598
31599 <div class="ep-card">
31600 <div class="ep-header">
31601 <span class="method delete">DELETE</span>
31602 <span class="ep-path">/api/schedules</span>
31603 <span class="auth-badge protected">Protected</span>
31604 <span class="ep-desc">Delete a schedule</span>
31605 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31606 </div>
31607 <div class="ep-body">
31608 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31609 <p class="curl-heading">Example</p>
31610 <div class="curl-wrap">
31611 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31612 -H "Authorization: Bearer $SLOC_API_KEY" \
31613 -H "Content-Type: application/json" \
31614 -d '{"id":"<schedule_id>"}' \
31615 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31616 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31617 </div>
31618 </div>
31619 </div>
31620 </div>
31621
31622 <!-- Git Browser -->
31623 <div class="section">
31624 <h2 class="section-title">Git Browser</h2>
31625
31626 <div class="ep-card">
31627 <div class="ep-header">
31628 <span class="method get">GET</span>
31629 <span class="ep-path">/api/git/refs</span>
31630 <span class="auth-badge protected">Protected</span>
31631 <span class="ep-desc">List git refs for a repository</span>
31632 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31633 </div>
31634 <div class="ep-body">
31635 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31636 <p class="params-heading">Query Parameters</p>
31637 <table class="params">
31638 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31639 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31640 </table>
31641 <p class="curl-heading">Example</p>
31642 <div class="curl-wrap">
31643 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31644 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31645 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31646 </div>
31647 </div>
31648 </div>
31649
31650 <div class="ep-card">
31651 <div class="ep-header">
31652 <span class="method get">GET</span>
31653 <span class="ep-path">/api/git/scan-ref</span>
31654 <span class="auth-badge protected">Protected</span>
31655 <span class="ep-desc">SLOC-scan a specific git ref</span>
31656 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31657 </div>
31658 <div class="ep-body">
31659 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31660 <p class="params-heading">Query Parameters</p>
31661 <table class="params">
31662 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31663 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31664 <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
31665 </table>
31666 <p class="curl-heading">Example</p>
31667 <div class="curl-wrap">
31668 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31669 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
31670 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31671 </div>
31672 </div>
31673 </div>
31674
31675 <div class="ep-card">
31676 <div class="ep-header">
31677 <span class="method get">GET</span>
31678 <span class="ep-path">/api/git/compare-refs</span>
31679 <span class="auth-badge protected">Protected</span>
31680 <span class="ep-desc">Compare SLOC across two git refs</span>
31681 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31682 </div>
31683 <div class="ep-body">
31684 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31685 <p class="params-heading">Query Parameters</p>
31686 <table class="params">
31687 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31688 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31689 <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
31690 <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
31691 </table>
31692 <p class="curl-heading">Example</p>
31693 <div class="curl-wrap">
31694 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31695 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&base=v1.0&head=main"</pre>
31696 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31697 </div>
31698 </div>
31699 </div>
31700 </div>
31701
31702 <!-- Webhooks -->
31703 <div class="section">
31704 <h2 class="section-title">Webhooks</h2>
31705 <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>
31706
31707 <div class="ep-card">
31708 <div class="ep-header">
31709 <span class="method post">POST</span>
31710 <span class="ep-path">/webhooks/github</span>
31711 <span class="auth-badge hmac">HMAC</span>
31712 <span class="ep-desc">GitHub push event receiver</span>
31713 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31714 </div>
31715 <div class="ep-body">
31716 <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>
31717 <p class="params-heading">Required Headers</p>
31718 <table class="params">
31719 <tr><th>Header</th><th>Value</th></tr>
31720 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31721 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31722 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31723 </table>
31724 </div>
31725 </div>
31726
31727 <div class="ep-card">
31728 <div class="ep-header">
31729 <span class="method post">POST</span>
31730 <span class="ep-path">/webhooks/gitlab</span>
31731 <span class="auth-badge hmac">HMAC</span>
31732 <span class="ep-desc">GitLab push event receiver</span>
31733 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31734 </div>
31735 <div class="ep-body">
31736 <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>
31737 <p class="params-heading">Required Headers</p>
31738 <table class="params">
31739 <tr><th>Header</th><th>Value</th></tr>
31740 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31741 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31742 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31743 </table>
31744 </div>
31745 </div>
31746
31747 <div class="ep-card">
31748 <div class="ep-header">
31749 <span class="method post">POST</span>
31750 <span class="ep-path">/webhooks/bitbucket</span>
31751 <span class="auth-badge hmac">HMAC</span>
31752 <span class="ep-desc">Bitbucket push event receiver</span>
31753 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31754 </div>
31755 <div class="ep-body">
31756 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31757 <p class="params-heading">Required Headers</p>
31758 <table class="params">
31759 <tr><th>Header</th><th>Value</th></tr>
31760 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31761 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31762 </table>
31763 </div>
31764 </div>
31765 </div>
31766
31767 <!-- Config -->
31768 <div class="section">
31769 <h2 class="section-title">Config Import / Export</h2>
31770
31771 <div class="ep-card">
31772 <div class="ep-header">
31773 <span class="method get">GET</span>
31774 <span class="ep-path">/export-config</span>
31775 <span class="auth-badge protected">Protected</span>
31776 <span class="ep-desc">Export server configuration as JSON</span>
31777 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31778 </div>
31779 <div class="ep-body">
31780 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31781 <p class="curl-heading">Example</p>
31782 <div class="curl-wrap">
31783 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31784 -o config.json \
31785 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31786 <button class="curl-copy-btn" data-target="c-export">Copy</button>
31787 </div>
31788 </div>
31789 </div>
31790
31791 <div class="ep-card">
31792 <div class="ep-header">
31793 <span class="method post">POST</span>
31794 <span class="ep-path">/import-config</span>
31795 <span class="auth-badge protected">Protected</span>
31796 <span class="ep-desc">Import server configuration</span>
31797 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31798 </div>
31799 <div class="ep-body">
31800 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31801 <p class="curl-heading">Example</p>
31802 <div class="curl-wrap">
31803 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31804 -H "Authorization: Bearer $SLOC_API_KEY" \
31805 -H "Content-Type: application/json" \
31806 -d @config.json \
31807 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31808 <button class="curl-copy-btn" data-target="c-import">Copy</button>
31809 </div>
31810 </div>
31811 </div>
31812 </div>
31813
31814 <!-- CI Ingest -->
31815 <div class="section">
31816 <h2 class="section-title">CI Ingest</h2>
31817
31818 <div class="ep-card">
31819 <div class="ep-header">
31820 <span class="method post">POST</span>
31821 <span class="ep-path">/api/ingest</span>
31822 <span class="auth-badge protected">Protected</span>
31823 <span class="ep-desc">Push a pre-computed scan result from CI</span>
31824 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31825 </div>
31826 <div class="ep-body">
31827 <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>
31828 <p class="params-heading">Query Parameters</p>
31829 <table class="params">
31830 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31831 <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>
31832 </table>
31833 <p class="params-heading">Request Body (application/json)</p>
31834 <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>
31835 <details class="schema"><summary>Response schema</summary>
31836<div class="schema-block">// 201 Created
31837{
31838 "run_id": string, // UUID of the ingested run
31839 "view_url": string // relative URL to the report page
31840}</div></details>
31841 <p class="curl-heading">Example</p>
31842 <div class="curl-wrap">
31843 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31844 -H "Authorization: Bearer $SLOC_API_KEY" \
31845 -H "Content-Type: application/json" \
31846 -d @result.json \
31847 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31848 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31849 </div>
31850 </div>
31851 </div>
31852 </div>
31853
31854 <!-- Artifact Download -->
31855 <div class="section">
31856 <h2 class="section-title">Artifact Download</h2>
31857
31858 <div class="ep-card">
31859 <div class="ep-header">
31860 <span class="method get">GET</span>
31861 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31862 <span class="auth-badge protected">Protected</span>
31863 <span class="ep-desc">Download or view a scan artifact</span>
31864 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31865 </div>
31866 <div class="ep-body">
31867 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31868 <p class="params-heading">Path Parameters</p>
31869 <table class="params">
31870 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31871 <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>
31872 <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>
31873 </table>
31874 <p class="params-heading">Query Parameters</p>
31875 <table class="params">
31876 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31877 <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>
31878 </table>
31879 <p class="curl-heading">Example — download JSON result</p>
31880 <div class="curl-wrap">
31881 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31882 -o result.json \
31883 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
31884 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31885 </div>
31886 </div>
31887 </div>
31888 </div>
31889
31890 <!-- Embed Widget -->
31891 <div class="section">
31892 <h2 class="section-title">Embed Widget</h2>
31893
31894 <div class="ep-card">
31895 <div class="ep-header">
31896 <span class="method get">GET</span>
31897 <span class="ep-path">/embed/summary</span>
31898 <span class="auth-badge protected">Protected</span>
31899 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31900 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31901 </div>
31902 <div class="ep-body">
31903 <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>
31904 <p class="params-heading">Query Parameters</p>
31905 <table class="params">
31906 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31907 <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>
31908 <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>
31909 </table>
31910 <p class="curl-heading">Example</p>
31911 <div class="curl-wrap">
31912 <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"
31913 width="460" height="260" style="border:none"></iframe></pre>
31914 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31915 </div>
31916 </div>
31917 </div>
31918 </div>
31919
31920 <!-- Confluence Integration -->
31921 <div class="section">
31922 <h2 class="section-title">Confluence Integration</h2>
31923
31924 <div class="ep-card">
31925 <div class="ep-header">
31926 <span class="method get">GET</span>
31927 <span class="ep-path">/api/confluence/config</span>
31928 <span class="auth-badge protected">Protected</span>
31929 <span class="ep-desc">Get current Confluence configuration</span>
31930 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31931 </div>
31932 <div class="ep-body">
31933 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31934 <details class="schema"><summary>Response schema</summary>
31935<div class="schema-block">{
31936 "configured": boolean,
31937 "tier": "cloud" | "server",
31938 "base_url": string,
31939 "username": string,
31940 "api_token_set": boolean,
31941 "space_key": string,
31942 "parent_page_id": string | null,
31943 "schedule_auto_post": { "<schedule_id>": boolean }
31944}</div></details>
31945 <p class="curl-heading">Example</p>
31946 <div class="curl-wrap">
31947 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31948 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31949 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31950 </div>
31951 </div>
31952 </div>
31953
31954 <div class="ep-card">
31955 <div class="ep-header">
31956 <span class="method post">POST</span>
31957 <span class="ep-path">/api/confluence/config</span>
31958 <span class="auth-badge protected">Protected</span>
31959 <span class="ep-desc">Save Confluence configuration</span>
31960 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31961 </div>
31962 <div class="ep-body">
31963 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31964 <p class="params-heading">Request Body (application/json)</p>
31965 <table class="params">
31966 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31967 <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>
31968 <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>
31969 <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>
31970 <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>
31971 <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>
31972 <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>
31973 <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>
31974 </table>
31975 <details class="schema"><summary>Response schema</summary>
31976<div class="schema-block">{ "ok": true }</div></details>
31977 <p class="curl-heading">Example</p>
31978 <div class="curl-wrap">
31979 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31980 -H "Authorization: Bearer $SLOC_API_KEY" \
31981 -H "Content-Type: application/json" \
31982 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31983 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31984 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31985 </div>
31986 </div>
31987 </div>
31988
31989 <div class="ep-card">
31990 <div class="ep-header">
31991 <span class="method post">POST</span>
31992 <span class="ep-path">/api/confluence/test</span>
31993 <span class="auth-badge protected">Protected</span>
31994 <span class="ep-desc">Test Confluence connection</span>
31995 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31996 </div>
31997 <div class="ep-body">
31998 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31999 <details class="schema"><summary>Response schema</summary>
32000<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32001 <p class="curl-heading">Example</p>
32002 <div class="curl-wrap">
32003 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32004 -H "Authorization: Bearer $SLOC_API_KEY" \
32005 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32006 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32007 </div>
32008 </div>
32009 </div>
32010
32011 <div class="ep-card">
32012 <div class="ep-header">
32013 <span class="method post">POST</span>
32014 <span class="ep-path">/api/confluence/post</span>
32015 <span class="auth-badge protected">Protected</span>
32016 <span class="ep-desc">Publish a scan report to Confluence</span>
32017 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32018 </div>
32019 <div class="ep-body">
32020 <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>
32021 <p class="params-heading">Request Body (application/json)</p>
32022 <table class="params">
32023 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32024 <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>
32025 <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>
32026 <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>
32027 </table>
32028 <details class="schema"><summary>Response schema</summary>
32029<div class="schema-block">// 200 OK
32030{ "ok": true, "page_id": string }
32031
32032// 400 / 502 on error
32033{ "ok": false, "error": string }</div></details>
32034 <p class="curl-heading">Example</p>
32035 <div class="curl-wrap">
32036 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32037 -H "Authorization: Bearer $SLOC_API_KEY" \
32038 -H "Content-Type: application/json" \
32039 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32040 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32041 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32042 </div>
32043 </div>
32044 </div>
32045
32046 <div class="ep-card">
32047 <div class="ep-header">
32048 <span class="method get">GET</span>
32049 <span class="ep-path">/api/confluence/wiki-markup</span>
32050 <span class="auth-badge protected">Protected</span>
32051 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32052 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32053 </div>
32054 <div class="ep-body">
32055 <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>
32056 <p class="params-heading">Query Parameters</p>
32057 <table class="params">
32058 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32059 <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>
32060 </table>
32061 <p class="curl-heading">Example</p>
32062 <div class="curl-wrap">
32063 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32064 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32065 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32066 </div>
32067 </div>
32068 </div>
32069 </div>
32070
32071 <!-- Authentication -->
32072 <div class="section">
32073 <h2 class="section-title">Authentication</h2>
32074 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32075
32076 <div class="ep-card">
32077 <div class="ep-header">
32078 <span class="method get">GET</span>
32079 <span class="ep-path">/auth/login</span>
32080 <span class="auth-badge public">Public</span>
32081 <span class="ep-desc">Login page</span>
32082 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32083 </div>
32084 <div class="ep-body">
32085 <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>
32086 <p class="params-heading">Query Parameters</p>
32087 <table class="params">
32088 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32089 <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>
32090 <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>
32091 </table>
32092 </div>
32093 </div>
32094
32095 <div class="ep-card">
32096 <div class="ep-header">
32097 <span class="method post">POST</span>
32098 <span class="ep-path">/auth/login</span>
32099 <span class="auth-badge public">Public</span>
32100 <span class="ep-desc">Submit credentials and get a session cookie</span>
32101 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32102 </div>
32103 <div class="ep-body">
32104 <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>
32105 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32106 <table class="params">
32107 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32108 <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>
32109 <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>
32110 </table>
32111 <p class="curl-heading">Example</p>
32112 <div class="curl-wrap">
32113 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32114 -d "key=$SLOC_API_KEY&next=/" \
32115 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32116 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32117 </div>
32118 </div>
32119 </div>
32120 </div>
32121
32122 <!-- Coverage Suggestion -->
32123 <div class="section">
32124 <h2 class="section-title">Coverage Suggestion</h2>
32125
32126 <div class="ep-card">
32127 <div class="ep-header">
32128 <span class="method get">GET</span>
32129 <span class="ep-path">/api/suggest-coverage</span>
32130 <span class="auth-badge protected">Protected</span>
32131 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32132 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32133 </div>
32134 <div class="ep-body">
32135 <p class="ep-desc-full">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>
32136 <p class="params-heading">Query Parameters</p>
32137 <table class="params">
32138 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32139 <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>
32140 </table>
32141 <details class="schema"><summary>Response schema</summary>
32142<div class="schema-block">{
32143 "found": string | null, // absolute path to the coverage file, if detected
32144 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32145 "hint": string | null // shell command to generate coverage if not found
32146}</div></details>
32147 <p class="curl-heading">Example</p>
32148 <div class="curl-wrap">
32149 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32150 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32151 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32152 </div>
32153 </div>
32154 </div>
32155 </div>
32156
32157 </div>
32158
32159 <footer class="site-footer">
32160 local code analysis - metrics, history and reports
32161 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32162 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32163 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32164 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32165 · <a href="/api-docs" rel="noopener">REST API</a>
32166 </footer>
32167
32168 <script nonce="{{ csp_nonce }}">
32169 (function () {
32170 var base = window.location.origin;
32171 document.getElementById('base-url').textContent = base;
32172 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32173 el.textContent = base;
32174 });
32175
32176 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32177 hdr.addEventListener('click', function () {
32178 hdr.closest('.ep-card').classList.toggle('open');
32179 });
32180 });
32181
32182 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32183 btn.addEventListener('click', function () {
32184 var targetId = btn.dataset.target;
32185 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32186 if (!pre) return;
32187 navigator.clipboard.writeText(pre.textContent).then(function () {
32188 btn.textContent = 'Copied!';
32189 btn.classList.add('copied');
32190 setTimeout(function () {
32191 btn.textContent = 'Copy';
32192 btn.classList.remove('copied');
32193 }, 2000);
32194 });
32195 });
32196 });
32197
32198 var storageKey = 'oxide-sloc-theme';
32199 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32200 var themeBtn = document.getElementById('theme-toggle');
32201 if (themeBtn) {
32202 themeBtn.addEventListener('click', function () {
32203 var dark = document.body.classList.toggle('dark-theme');
32204 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32205 });
32206 }
32207 (function() {
32208 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'}];
32209 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);});}
32210 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32211 var btn=document.getElementById('settings-btn');if(!btn)return;
32212 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32213 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>';
32214 document.body.appendChild(m);
32215 var g=document.getElementById('scheme-grid');
32216 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);});
32217 var cl=document.getElementById('settings-close');
32218 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);});})();
32219 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');});
32220 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32221 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32222 })();
32223 (function randomizeWatermarks() {
32224 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32225 if (!wms.length) return;
32226 var placed = [];
32227 function tooClose(top, left) {
32228 for (var i = 0; i < placed.length; i++) {
32229 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32230 if (dt < 16 && dl < 12) return true;
32231 }
32232 return false;
32233 }
32234 function pick(leftBand) {
32235 for (var attempt = 0; attempt < 50; attempt++) {
32236 var top = Math.random() * 88 + 2;
32237 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32238 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32239 }
32240 var top = Math.random() * 88 + 2;
32241 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32242 placed.push([top, left]); return [top, left];
32243 }
32244 var half = Math.floor(wms.length / 2);
32245 wms.forEach(function (img, i) {
32246 var pos = pick(i < half);
32247 var size = Math.floor(Math.random() * 100 + 120);
32248 var rot = (Math.random() * 360).toFixed(1);
32249 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32250 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;
32251 });
32252 })();
32253 (function spawnCodeParticles() {
32254 var container = document.getElementById('code-particles');
32255 if (!container) return;
32256 var snippets = [
32257 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32258 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32259 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32260 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32261 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32262 ];
32263 var count = 38;
32264 for (var i = 0; i < count; i++) {
32265 (function(idx) {
32266 var el = document.createElement('span');
32267 el.className = 'code-particle';
32268 el.textContent = snippets[idx % snippets.length];
32269 var left = Math.random() * 94 + 2;
32270 var top = Math.random() * 88 + 6;
32271 var dur = (Math.random() * 10 + 9).toFixed(1);
32272 var delay = (Math.random() * 18).toFixed(1);
32273 var rot = (Math.random() * 26 - 13).toFixed(1);
32274 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32275 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32276 container.appendChild(el);
32277 })(i);
32278 }
32279 })();
32280 }());
32281 </script>
32282</body>
32283</html>
32284"##,
32285 ext = "html"
32286)]
32287struct ApiDocsTemplate {
32288 has_api_key: bool,
32289 csp_nonce: String,
32290 version: &'static str,
32291}
32292
32293#[cfg(test)]
32294mod form_config_tests {
32295 use super::*;
32296 use sloc_config::{
32297 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32298 };
32299
32300 fn blank_form() -> AnalyzeForm {
32301 AnalyzeForm {
32302 path: ".".to_string(),
32303 git_repo: None,
32304 git_ref: None,
32305 mixed_line_policy: None,
32306 python_docstrings_as_comments: None,
32307 generated_file_detection: None,
32308 minified_file_detection: None,
32309 vendor_directory_detection: None,
32310 include_lockfiles: None,
32311 binary_file_behavior: None,
32312 output_dir: None,
32313 report_title: None,
32314 report_header_footer: None,
32315 include_globs: None,
32316 exclude_globs: None,
32317 submodule_breakdown: None,
32318 coverage_file: None,
32319 continuation_line_policy: None,
32320 blank_in_block_comment_policy: None,
32321 count_compiler_directives: None,
32322 style_col_threshold: None,
32323 style_analysis_enabled: None,
32324 style_score_threshold: None,
32325 style_lang_scope: None,
32326 cocomo_mode: None,
32327 complexity_alert: None,
32328 exclude_duplicates: None,
32329 activity_window: None,
32330 }
32331 }
32332
32333 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32334 let mut cfg = sloc_config::AppConfig::default();
32335 apply_form_to_config(&mut cfg, form);
32336 cfg
32337 }
32338
32339 #[test]
32342 fn extract_long_commit_picks_super_repo_by_short_prefix() {
32343 let dir = tempfile::tempdir().unwrap();
32347 let path = dir.path().join("result.json");
32348 let body = r#"{
32349 "submodules": [
32350 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32351 { "git_commit_long": null }
32352 ],
32353 "git_commit_short": "4c2cd9b",
32354 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32355}"#;
32356 std::fs::write(&path, body).unwrap();
32357 assert_eq!(
32358 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32359 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32360 );
32361 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32363 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32364 }
32365
32366 #[test]
32367 fn activity_window_defaults_on_when_field_blank() {
32368 let cfg = apply(&blank_form());
32370 assert_eq!(cfg.analysis.activity_window_days, Some(90));
32371 }
32372
32373 #[test]
32374 fn activity_window_override_sets_days() {
32375 let mut form = blank_form();
32376 form.activity_window = Some("30".to_string());
32377 let cfg = apply(&form);
32378 assert_eq!(cfg.analysis.activity_window_days, Some(30));
32379 }
32380
32381 #[test]
32382 fn activity_window_zero_disables() {
32383 let mut form = blank_form();
32385 form.activity_window = Some("0".to_string());
32386 let cfg = apply(&form);
32387 assert_eq!(cfg.analysis.activity_window_days, Some(0));
32388 }
32389
32390 #[test]
32393 fn python_docstrings_false_when_unchecked() {
32394 let cfg = apply(&blank_form());
32396 assert!(
32397 !cfg.analysis.python_docstrings_as_comments,
32398 "absent python_docstrings_as_comments must map to false"
32399 );
32400 }
32401
32402 #[test]
32403 fn python_docstrings_true_when_checked() {
32404 let mut form = blank_form();
32406 form.python_docstrings_as_comments = Some("on".to_string());
32407 let cfg = apply(&form);
32408 assert!(cfg.analysis.python_docstrings_as_comments);
32409 }
32410
32411 #[test]
32412 fn python_docstrings_true_for_any_non_none_value() {
32413 let mut form = blank_form();
32415 form.python_docstrings_as_comments = Some("true".to_string());
32416 assert!(apply(&form).analysis.python_docstrings_as_comments);
32417 }
32418
32419 #[test]
32422 fn submodule_breakdown_false_when_unchecked() {
32423 let cfg = apply(&blank_form());
32424 assert!(
32425 !cfg.discovery.submodule_breakdown,
32426 "absent submodule_breakdown must map to false"
32427 );
32428 }
32429
32430 #[test]
32431 fn submodule_breakdown_true_when_value_enabled() {
32432 let mut form = blank_form();
32433 form.submodule_breakdown = Some("enabled".to_string());
32434 assert!(apply(&form).discovery.submodule_breakdown);
32435 }
32436
32437 #[test]
32438 fn submodule_breakdown_false_for_wrong_value() {
32439 let mut form = blank_form();
32441 form.submodule_breakdown = Some("on".to_string());
32442 assert!(
32443 !apply(&form).discovery.submodule_breakdown,
32444 "submodule_breakdown only becomes true for the exact value 'enabled'"
32445 );
32446 }
32447
32448 #[test]
32451 fn generated_detection_true_when_enabled() {
32452 let mut form = blank_form();
32453 form.generated_file_detection = Some("enabled".to_string());
32454 assert!(apply(&form).analysis.generated_file_detection);
32455 }
32456
32457 #[test]
32458 fn generated_detection_false_when_disabled() {
32459 let mut form = blank_form();
32460 form.generated_file_detection = Some("disabled".to_string());
32461 assert!(!apply(&form).analysis.generated_file_detection);
32462 }
32463
32464 #[test]
32465 fn generated_detection_true_when_absent() {
32466 assert!(
32468 apply(&blank_form()).analysis.generated_file_detection,
32469 "absent field must default to true (detection on)"
32470 );
32471 }
32472
32473 #[test]
32476 fn minified_detection_false_when_disabled() {
32477 let mut form = blank_form();
32478 form.minified_file_detection = Some("disabled".to_string());
32479 assert!(!apply(&form).analysis.minified_file_detection);
32480 }
32481
32482 #[test]
32483 fn minified_detection_true_when_enabled() {
32484 let mut form = blank_form();
32485 form.minified_file_detection = Some("enabled".to_string());
32486 assert!(apply(&form).analysis.minified_file_detection);
32487 }
32488
32489 #[test]
32490 fn minified_detection_true_when_absent() {
32491 assert!(apply(&blank_form()).analysis.minified_file_detection);
32492 }
32493
32494 #[test]
32497 fn vendor_detection_false_when_disabled() {
32498 let mut form = blank_form();
32499 form.vendor_directory_detection = Some("disabled".to_string());
32500 assert!(!apply(&form).analysis.vendor_directory_detection);
32501 }
32502
32503 #[test]
32504 fn vendor_detection_true_when_enabled() {
32505 let mut form = blank_form();
32506 form.vendor_directory_detection = Some("enabled".to_string());
32507 assert!(apply(&form).analysis.vendor_directory_detection);
32508 }
32509
32510 #[test]
32511 fn vendor_detection_true_when_absent() {
32512 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32513 }
32514
32515 #[test]
32518 fn lockfiles_false_when_absent() {
32519 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32521 }
32522
32523 #[test]
32524 fn lockfiles_false_when_disabled() {
32525 let mut form = blank_form();
32526 form.include_lockfiles = Some("disabled".to_string());
32527 assert!(!apply(&form).analysis.include_lockfiles);
32528 }
32529
32530 #[test]
32531 fn lockfiles_true_when_enabled() {
32532 let mut form = blank_form();
32533 form.include_lockfiles = Some("enabled".to_string());
32534 assert!(apply(&form).analysis.include_lockfiles);
32535 }
32536
32537 #[test]
32540 fn compiler_directives_true_when_absent() {
32541 assert!(
32542 apply(&blank_form()).analysis.count_compiler_directives,
32543 "absent count_compiler_directives must default to true"
32544 );
32545 }
32546
32547 #[test]
32548 fn compiler_directives_true_when_enabled() {
32549 let mut form = blank_form();
32550 form.count_compiler_directives = Some("enabled".to_string());
32551 assert!(apply(&form).analysis.count_compiler_directives);
32552 }
32553
32554 #[test]
32555 fn compiler_directives_false_when_disabled() {
32556 let mut form = blank_form();
32557 form.count_compiler_directives = Some("disabled".to_string());
32558 assert!(!apply(&form).analysis.count_compiler_directives);
32559 }
32560
32561 #[test]
32564 fn mixed_policy_unchanged_when_absent() {
32565 assert_eq!(
32567 apply(&blank_form()).analysis.mixed_line_policy,
32568 MixedLinePolicy::CodeOnly
32569 );
32570 }
32571
32572 #[test]
32573 fn mixed_policy_code_only() {
32574 let mut form = blank_form();
32575 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32576 assert_eq!(
32577 apply(&form).analysis.mixed_line_policy,
32578 MixedLinePolicy::CodeOnly
32579 );
32580 }
32581
32582 #[test]
32583 fn mixed_policy_code_and_comment() {
32584 let mut form = blank_form();
32585 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32586 assert_eq!(
32587 apply(&form).analysis.mixed_line_policy,
32588 MixedLinePolicy::CodeAndComment
32589 );
32590 }
32591
32592 #[test]
32593 fn mixed_policy_comment_only() {
32594 let mut form = blank_form();
32595 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32596 assert_eq!(
32597 apply(&form).analysis.mixed_line_policy,
32598 MixedLinePolicy::CommentOnly
32599 );
32600 }
32601
32602 #[test]
32603 fn mixed_policy_separate_mixed_category() {
32604 let mut form = blank_form();
32605 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32606 assert_eq!(
32607 apply(&form).analysis.mixed_line_policy,
32608 MixedLinePolicy::SeparateMixedCategory
32609 );
32610 }
32611
32612 #[test]
32615 fn binary_behavior_skip_when_absent() {
32616 assert_eq!(
32617 apply(&blank_form()).analysis.binary_file_behavior,
32618 BinaryFileBehavior::Skip
32619 );
32620 }
32621
32622 #[test]
32623 fn binary_behavior_skip() {
32624 let mut form = blank_form();
32625 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32626 assert_eq!(
32627 apply(&form).analysis.binary_file_behavior,
32628 BinaryFileBehavior::Skip
32629 );
32630 }
32631
32632 #[test]
32633 fn binary_behavior_fail() {
32634 let mut form = blank_form();
32635 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32636 assert_eq!(
32637 apply(&form).analysis.binary_file_behavior,
32638 BinaryFileBehavior::Fail
32639 );
32640 }
32641
32642 #[test]
32645 fn continuation_policy_each_physical_when_absent() {
32646 assert_eq!(
32647 apply(&blank_form()).analysis.continuation_line_policy,
32648 ContinuationLinePolicy::EachPhysicalLine
32649 );
32650 }
32651
32652 #[test]
32653 fn continuation_policy_collapse_to_logical() {
32654 let mut form = blank_form();
32655 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32656 assert_eq!(
32657 apply(&form).analysis.continuation_line_policy,
32658 ContinuationLinePolicy::CollapseToLogical
32659 );
32660 }
32661
32662 #[test]
32665 fn blank_in_block_comment_count_as_comment_when_absent() {
32666 assert_eq!(
32667 apply(&blank_form()).analysis.blank_in_block_comment_policy,
32668 BlankInBlockCommentPolicy::CountAsComment
32669 );
32670 }
32671
32672 #[test]
32673 fn blank_in_block_comment_count_as_blank() {
32674 let mut form = blank_form();
32675 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32676 assert_eq!(
32677 apply(&form).analysis.blank_in_block_comment_policy,
32678 BlankInBlockCommentPolicy::CountAsBlank
32679 );
32680 }
32681
32682 #[test]
32685 fn style_threshold_80() {
32686 let mut form = blank_form();
32687 form.style_col_threshold = Some("80".to_string());
32688 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32689 }
32690
32691 #[test]
32692 fn style_threshold_100() {
32693 let mut form = blank_form();
32694 form.style_col_threshold = Some("100".to_string());
32695 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32696 }
32697
32698 #[test]
32699 fn style_threshold_120() {
32700 let mut form = blank_form();
32701 form.style_col_threshold = Some("120".to_string());
32702 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32703 }
32704
32705 #[test]
32706 fn style_threshold_invalid_value_leaves_default() {
32707 let mut cfg = sloc_config::AppConfig::default();
32709 let mut form = blank_form();
32710 form.style_col_threshold = Some("42".to_string());
32711 apply_form_to_config(&mut cfg, &form);
32712 assert_eq!(
32713 cfg.analysis.style_col_threshold, 80,
32714 "invalid threshold must not change config"
32715 );
32716 }
32717
32718 #[test]
32719 fn style_threshold_non_numeric_leaves_default() {
32720 let mut cfg = sloc_config::AppConfig::default();
32721 let mut form = blank_form();
32722 form.style_col_threshold = Some("large".to_string());
32723 apply_form_to_config(&mut cfg, &form);
32724 assert_eq!(cfg.analysis.style_col_threshold, 80);
32725 }
32726
32727 #[test]
32728 fn style_threshold_zero_leaves_default() {
32729 let mut cfg = sloc_config::AppConfig::default();
32730 let mut form = blank_form();
32731 form.style_col_threshold = Some("0".to_string());
32732 apply_form_to_config(&mut cfg, &form);
32733 assert_eq!(cfg.analysis.style_col_threshold, 80);
32734 }
32735
32736 #[test]
32737 fn style_threshold_absent_leaves_default() {
32738 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32739 }
32740
32741 #[test]
32744 fn style_score_threshold_zero_when_absent() {
32745 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32746 }
32747
32748 #[test]
32749 fn style_score_threshold_set_to_valid_value() {
32750 let mut form = blank_form();
32751 form.style_score_threshold = Some("70".to_string());
32752 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32753 }
32754
32755 #[test]
32756 fn style_score_threshold_clamps_to_100_when_over() {
32757 let mut form = blank_form();
32759 form.style_score_threshold = Some("200".to_string());
32760 assert_eq!(
32761 apply(&form).analysis.style_score_threshold,
32762 100,
32763 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32764 );
32765 }
32766
32767 #[test]
32770 fn coverage_file_none_when_absent() {
32771 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32772 }
32773
32774 #[test]
32775 fn coverage_file_none_when_whitespace_only() {
32776 let mut form = blank_form();
32777 form.coverage_file = Some(" ".to_string());
32778 assert!(
32779 apply(&form).analysis.coverage_file.is_none(),
32780 "whitespace-only coverage_file must be treated as None"
32781 );
32782 }
32783
32784 #[test]
32785 fn coverage_file_set_when_non_empty() {
32786 let mut form = blank_form();
32787 form.coverage_file = Some("coverage/lcov.info".to_string());
32788 assert_eq!(
32789 apply(&form).analysis.coverage_file,
32790 Some(std::path::PathBuf::from("coverage/lcov.info"))
32791 );
32792 }
32793
32794 #[test]
32795 fn coverage_file_trims_whitespace() {
32796 let mut form = blank_form();
32797 form.coverage_file = Some(" coverage/lcov.info ".to_string());
32798 assert_eq!(
32799 apply(&form).analysis.coverage_file,
32800 Some(std::path::PathBuf::from("coverage/lcov.info"))
32801 );
32802 }
32803
32804 #[test]
32807 fn report_title_unchanged_when_absent() {
32808 let original = sloc_config::AppConfig::default().reporting.report_title;
32809 assert_eq!(apply(&blank_form()).reporting.report_title, original);
32810 }
32811
32812 #[test]
32813 fn report_title_unchanged_when_whitespace_only() {
32814 let original = sloc_config::AppConfig::default().reporting.report_title;
32815 let mut form = blank_form();
32816 form.report_title = Some(" ".to_string());
32817 assert_eq!(
32818 apply(&form).reporting.report_title,
32819 original,
32820 "whitespace-only title must not overwrite the default"
32821 );
32822 }
32823
32824 #[test]
32825 fn report_title_updated_and_trimmed() {
32826 let mut form = blank_form();
32827 form.report_title = Some(" My Project ".to_string());
32828 assert_eq!(apply(&form).reporting.report_title, "My Project");
32829 }
32830
32831 #[test]
32834 fn header_footer_none_when_absent() {
32835 assert!(apply(&blank_form())
32836 .reporting
32837 .report_header_footer
32838 .is_none());
32839 }
32840
32841 #[test]
32842 fn header_footer_none_when_whitespace_only() {
32843 let mut form = blank_form();
32844 form.report_header_footer = Some(" ".to_string());
32845 assert!(apply(&form).reporting.report_header_footer.is_none());
32846 }
32847
32848 #[test]
32849 fn header_footer_set_and_trimmed() {
32850 let mut form = blank_form();
32851 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
32852 assert_eq!(
32853 apply(&form).reporting.report_header_footer,
32854 Some("Confidential — Internal Use".to_string())
32855 );
32856 }
32857
32858 #[test]
32861 fn include_globs_empty_when_absent() {
32862 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32863 }
32864
32865 #[test]
32866 fn include_globs_newline_separated() {
32867 let mut form = blank_form();
32868 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32869 assert_eq!(
32870 apply(&form).discovery.include_globs,
32871 vec!["src/**/*.rs", "tests/**/*.rs"]
32872 );
32873 }
32874
32875 #[test]
32876 fn exclude_globs_comma_separated() {
32877 let mut form = blank_form();
32878 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32879 assert_eq!(
32880 apply(&form).discovery.exclude_globs,
32881 vec!["vendor/**", "node_modules/**"]
32882 );
32883 }
32884
32885 #[test]
32886 fn globs_mixed_separators() {
32887 let mut form = blank_form();
32888 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32889 assert_eq!(
32890 apply(&form).discovery.exclude_globs,
32891 vec!["a/**", "b/**", "c/**"]
32892 );
32893 }
32894
32895 #[test]
32898 fn split_patterns_none_is_empty() {
32899 assert!(split_patterns(None).is_empty());
32900 }
32901
32902 #[test]
32903 fn split_patterns_empty_string_is_empty() {
32904 assert!(split_patterns(Some("")).is_empty());
32905 }
32906
32907 #[test]
32908 fn split_patterns_whitespace_only_is_empty() {
32909 assert!(split_patterns(Some(" \n \n ")).is_empty());
32910 }
32911
32912 #[test]
32913 fn split_patterns_newlines() {
32914 assert_eq!(
32915 split_patterns(Some("a/**\nb/**\nc/**")),
32916 vec!["a/**", "b/**", "c/**"]
32917 );
32918 }
32919
32920 #[test]
32921 fn split_patterns_commas() {
32922 assert_eq!(
32923 split_patterns(Some("a/**,b/**,c/**")),
32924 vec!["a/**", "b/**", "c/**"]
32925 );
32926 }
32927
32928 #[test]
32929 fn split_patterns_mixed() {
32930 assert_eq!(
32931 split_patterns(Some("a/**\nb/**,c/**")),
32932 vec!["a/**", "b/**", "c/**"]
32933 );
32934 }
32935
32936 #[test]
32937 fn split_patterns_trims_whitespace() {
32938 assert_eq!(
32939 split_patterns(Some(" a/** \n b/** ")),
32940 vec!["a/**", "b/**"]
32941 );
32942 }
32943
32944 #[test]
32945 fn split_patterns_filters_empty_entries() {
32946 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32947 }
32948
32949 #[test]
32950 fn split_patterns_single_entry() {
32951 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32952 }
32953}
32954
32955#[cfg(test)]
32956mod utility_tests {
32957 use super::*;
32958 use std::net::IpAddr;
32959 use std::time::Duration;
32960
32961 #[test]
32964 fn sanitize_simple_name() {
32965 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32966 }
32967
32968 #[test]
32969 fn sanitize_uppercased_lowercased() {
32970 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32971 }
32972
32973 #[test]
32974 fn sanitize_path_extracts_filename() {
32975 assert_eq!(
32976 sanitize_project_label("/home/user/my-project"),
32977 "my-project"
32978 );
32979 }
32980
32981 #[test]
32982 fn sanitize_path_uses_last_component() {
32983 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32984 }
32985
32986 #[test]
32987 fn sanitize_spaces_become_hyphens() {
32988 assert_eq!(sanitize_project_label("my project"), "my-project");
32989 }
32990
32991 #[test]
32992 fn sanitize_non_ascii_become_hyphens() {
32993 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32994 }
32995
32996 #[test]
32997 fn sanitize_all_special_chars_gives_project() {
32998 assert_eq!(sanitize_project_label("!@#$%^"), "project");
32999 }
33000
33001 #[test]
33002 fn sanitize_empty_string_gives_project() {
33003 assert_eq!(sanitize_project_label(""), "project");
33004 }
33005
33006 #[test]
33007 fn sanitize_leading_trailing_hyphens_stripped() {
33008 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33009 }
33010
33011 #[test]
33012 fn sanitize_alphanumeric_preserved() {
33013 assert_eq!(sanitize_project_label("repo123"), "repo123");
33014 }
33015
33016 #[test]
33017 fn sanitize_dots_become_hyphens() {
33018 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33019 }
33020
33021 #[test]
33022 fn sanitize_mixed_slashes_uses_filename() {
33023 assert_eq!(sanitize_project_label("project-name"), "project-name");
33025 }
33026
33027 #[test]
33030 fn rate_limiter_allows_first_request() {
33031 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33032 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33033 assert!(rl.is_allowed(ip));
33034 }
33035
33036 #[test]
33037 fn rate_limiter_blocks_after_limit_reached() {
33038 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33039 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33040 assert!(rl.is_allowed(ip));
33041 assert!(rl.is_allowed(ip));
33042 assert!(rl.is_allowed(ip));
33043 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33044 }
33045
33046 #[test]
33047 fn rate_limiter_allows_requests_up_to_limit() {
33048 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33049 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33050 for _ in 0..5 {
33051 assert!(rl.is_allowed(ip));
33052 }
33053 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33054 }
33055
33056 #[test]
33057 fn rate_limiter_different_ips_are_independent() {
33058 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33059 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33060 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33061 assert!(rl.is_allowed(ip1));
33062 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33063 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33064 }
33065
33066 #[test]
33067 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33068 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33069 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33070 rl.record_auth_failure(ip);
33071 rl.record_auth_failure(ip);
33072 assert!(
33073 !rl.is_auth_locked_out(ip),
33074 "not locked at 2 failures when threshold is 3"
33075 );
33076 }
33077
33078 #[test]
33079 fn rate_limiter_auth_failure_locked_at_threshold() {
33080 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33081 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33082 rl.record_auth_failure(ip);
33083 rl.record_auth_failure(ip);
33084 rl.record_auth_failure(ip);
33085 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33086 }
33087
33088 #[test]
33089 fn rate_limiter_auth_failure_different_ips_independent() {
33090 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33091 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33092 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33093 rl.record_auth_failure(ip1);
33094 rl.record_auth_failure(ip1);
33095 assert!(rl.is_auth_locked_out(ip1));
33096 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33097 }
33098
33099 #[test]
33100 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33101 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33102 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33103 for _ in 0..100 {
33104 assert!(rl.is_allowed(ip));
33105 }
33106 }
33107
33108 #[test]
33111 fn strip_unc_plain_path_unchanged() {
33112 let p = PathBuf::from("C:\\Users\\user\\project");
33113 let result = strip_unc_prefix(p.clone());
33114 assert_eq!(result, p);
33115 }
33116
33117 #[test]
33118 fn strip_unc_with_drive_prefix_stripped() {
33119 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33120 let result = strip_unc_prefix(p);
33121 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33122 }
33123
33124 #[test]
33125 fn strip_unc_with_network_prefix_stripped() {
33126 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33127 let result = strip_unc_prefix(p);
33128 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33129 }
33130
33131 #[test]
33132 fn strip_unc_linux_path_unchanged() {
33133 let p = PathBuf::from("/home/user/project");
33134 let result = strip_unc_prefix(p.clone());
33135 assert_eq!(result, p);
33136 }
33137
33138 #[test]
33141 fn remote_to_commit_url_github_https() {
33142 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33143 assert_eq!(
33144 url,
33145 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33146 );
33147 }
33148
33149 #[test]
33150 fn remote_to_commit_url_github_ssh() {
33151 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33152 assert_eq!(
33153 url,
33154 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33155 );
33156 }
33157
33158 #[test]
33159 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33160 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33161 assert_eq!(
33162 url,
33163 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33164 );
33165 }
33166
33167 #[test]
33168 fn remote_to_commit_url_bitbucket_uses_commits() {
33169 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33170 assert_eq!(
33171 url,
33172 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33173 );
33174 }
33175
33176 #[test]
33177 fn remote_to_commit_url_unknown_scheme_returns_none() {
33178 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33179 assert!(url.is_none());
33180 }
33181
33182 #[test]
33183 fn remote_to_commit_url_ssh_gitlab() {
33184 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33185 assert!(url.is_some());
33186 let u = url.unwrap();
33187 assert!(
33188 u.contains("/-/commit/sha123"),
33189 "gitlab ssh must use /-/commit/"
33190 );
33191 }
33192
33193 #[test]
33196 fn git_clone_dest_github_url_produces_safe_name() {
33197 let dir = PathBuf::from("/tmp/clones");
33198 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33199 let name = dest.file_name().unwrap().to_string_lossy();
33200 assert!(!name.is_empty());
33201 assert!(
33202 name.chars()
33203 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33204 "clone dest must only contain safe chars, got: {name}"
33205 );
33206 }
33207
33208 #[test]
33209 fn git_clone_dest_is_inside_clones_dir() {
33210 let dir = PathBuf::from("/tmp/clones");
33211 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33212 assert!(
33213 dest.starts_with(&dir),
33214 "clone dest must be inside clones_dir"
33215 );
33216 }
33217
33218 #[test]
33219 fn git_clone_dest_truncates_to_80_chars_max() {
33220 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33221 let dir = PathBuf::from("/tmp/clones");
33222 let dest = git_clone_dest(&long_url, &dir);
33223 let name = dest.file_name().unwrap().to_string_lossy();
33224 assert!(
33225 name.len() <= 80,
33226 "clone dest name must be at most 80 chars, got {} chars: {name}",
33227 name.len()
33228 );
33229 }
33230
33231 #[test]
33232 fn git_clone_dest_special_chars_replaced_with_underscore() {
33233 let dir = PathBuf::from("/tmp/clones");
33234 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33235 let name = dest.file_name().unwrap().to_string_lossy();
33236 assert!(
33237 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33238 "special chars must be replaced in clone dest, got: {name}"
33239 );
33240 }
33241
33242 #[test]
33243 fn git_clone_dest_different_urls_differ() {
33244 let dir = PathBuf::from("/tmp/clones");
33245 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33246 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33247 assert_ne!(
33248 a, b,
33249 "different repos must produce different clone dest names"
33250 );
33251 }
33252
33253 #[test]
33254 fn git_clone_dest_same_url_same_result() {
33255 let dir = PathBuf::from("/tmp/clones");
33256 let url = "https://github.com/owner/repo.git";
33257 assert_eq!(
33258 git_clone_dest(url, &dir),
33259 git_clone_dest(url, &dir),
33260 "same URL must always give same clone dest"
33261 );
33262 }
33263
33264 #[test]
33267 fn fmt_delta_positive_has_plus_prefix() {
33268 assert_eq!(fmt_delta(5), "+5");
33269 }
33270
33271 #[test]
33272 fn fmt_delta_negative_no_plus_prefix() {
33273 assert_eq!(fmt_delta(-3), "-3");
33274 }
33275
33276 #[test]
33277 fn fmt_delta_zero() {
33278 assert_eq!(fmt_delta(0), "0");
33279 }
33280
33281 #[test]
33284 fn delta_class_positive_is_pos() {
33285 assert_eq!(delta_class(1), "pos");
33286 }
33287
33288 #[test]
33289 fn delta_class_negative_is_neg() {
33290 assert_eq!(delta_class(-1), "neg");
33291 }
33292
33293 #[test]
33294 fn delta_class_zero_is_zero_class() {
33295 assert_eq!(delta_class(0), "zero");
33296 }
33297
33298 #[test]
33301 fn fmt_pct_zero_baseline_returns_em_dash() {
33302 assert_eq!(fmt_pct(100, 0), "\u{2014}");
33303 }
33304
33305 #[test]
33306 fn fmt_pct_positive_delta_has_plus_sign() {
33307 let result = fmt_pct(10, 100);
33308 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33309 }
33310
33311 #[test]
33312 fn fmt_pct_negative_delta_no_plus_sign() {
33313 let result = fmt_pct(-10, 100);
33314 assert!(!result.starts_with('+'), "unexpected + in: {result}");
33315 assert!(result.contains('%'));
33316 }
33317
33318 #[test]
33319 fn fmt_pct_near_zero_returns_pm_zero() {
33320 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33321 }
33322
33323 #[test]
33326 fn summary_delta_no_prev_returns_dash_na() {
33327 let (display, class) = summary_delta(10, None);
33328 assert_eq!(display, "\u{2014}");
33329 assert_eq!(class, "na");
33330 }
33331
33332 #[test]
33333 fn summary_delta_increase_is_positive() {
33334 let (display, class) = summary_delta(15, Some(10));
33335 assert_eq!(display, "+5");
33336 assert_eq!(class, "pos");
33337 }
33338
33339 #[test]
33340 fn summary_delta_decrease_is_negative() {
33341 let (display, class) = summary_delta(5, Some(10));
33342 assert_eq!(display, "-5");
33343 assert_eq!(class, "neg");
33344 }
33345
33346 #[test]
33349 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33350 use chrono::Datelike;
33351 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33352 assert_eq!(d.year(), 2024);
33353 assert_eq!(d.month(), 1);
33354 assert_eq!(d.weekday(), chrono::Weekday::Mon);
33355 assert!(d.day() <= 7);
33356 }
33357
33358 #[test]
33359 fn nth_weekday_second_sunday_march_2024_is_10th() {
33360 use chrono::Datelike;
33361 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33362 assert_eq!(d.weekday(), chrono::Weekday::Sun);
33363 assert_eq!(d.month(), 3);
33364 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33365 }
33366
33367 #[test]
33370 fn is_pacific_dst_july_is_true() {
33371 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33372 assert!(is_pacific_dst(dt), "July must be PDT");
33373 }
33374
33375 #[test]
33376 fn is_pacific_dst_january_is_false() {
33377 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33378 assert!(!is_pacific_dst(dt), "January must be PST");
33379 }
33380
33381 #[test]
33382 fn fmt_la_time_summer_shows_pdt() {
33383 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33384 let result = fmt_la_time(dt);
33385 assert!(
33386 result.ends_with("PDT"),
33387 "summer must use PDT, got: {result}"
33388 );
33389 }
33390
33391 #[test]
33392 fn fmt_la_time_winter_shows_pst() {
33393 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33394 let result = fmt_la_time(dt);
33395 assert!(
33396 result.ends_with("PST"),
33397 "winter must use PST, got: {result}"
33398 );
33399 }
33400
33401 #[test]
33402 fn fmt_la_time_meta_summer_shows_pdt() {
33403 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33404 let result = fmt_la_time_meta(dt);
33405 assert!(
33406 result.ends_with("PDT"),
33407 "meta summer must use PDT, got: {result}"
33408 );
33409 }
33410
33411 #[test]
33412 fn fmt_la_time_meta_winter_shows_pst() {
33413 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33414 let result = fmt_la_time_meta(dt);
33415 assert!(
33416 result.ends_with("PST"),
33417 "meta winter must use PST, got: {result}"
33418 );
33419 }
33420
33421 #[test]
33424 fn fmt_git_date_valid_iso_returns_some() {
33425 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33426 }
33427
33428 #[test]
33429 fn fmt_git_date_invalid_returns_none() {
33430 assert!(fmt_git_date("not-a-date").is_none());
33431 }
33432
33433 #[test]
33436 fn format_number_zero() {
33437 assert_eq!(format_number(0), "0");
33438 }
33439
33440 #[test]
33441 fn format_number_three_digits_no_comma() {
33442 assert_eq!(format_number(999), "999");
33443 }
33444
33445 #[test]
33446 fn format_number_four_digits_has_comma() {
33447 assert_eq!(format_number(1000), "1,000");
33448 }
33449
33450 #[test]
33451 fn format_number_seven_digits_two_commas() {
33452 assert_eq!(format_number(1_234_567), "1,234,567");
33453 }
33454
33455 #[test]
33456 fn format_number_one_million() {
33457 assert_eq!(format_number(1_000_000), "1,000,000");
33458 }
33459
33460 #[test]
33463 fn badge_text_px_empty_is_zero() {
33464 assert_eq!(badge_text_px(""), 0);
33465 }
33466
33467 #[test]
33468 fn badge_text_px_narrow_chars_smaller_than_normal() {
33469 assert!(
33470 badge_text_px("if") < badge_text_px("ab"),
33471 "'if' must be narrower than 'ab'"
33472 );
33473 }
33474
33475 #[test]
33476 fn badge_text_px_m_is_wider_than_a() {
33477 assert!(
33478 badge_text_px("m") > badge_text_px("a"),
33479 "'m' must be wider than 'a'"
33480 );
33481 }
33482
33483 #[test]
33484 fn render_badge_svg_contains_label_and_value() {
33485 let svg = render_badge_svg("coverage", "95%", "#4c1");
33486 assert!(svg.contains("coverage") && svg.contains("95%"));
33487 }
33488
33489 #[test]
33490 fn render_badge_svg_contains_color() {
33491 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33492 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33493 }
33494
33495 #[test]
33496 fn render_badge_svg_escapes_ampersand_in_label() {
33497 let svg = render_badge_svg("test&label", "ok", "#4c1");
33498 assert!(svg.contains("&") && !svg.contains("test&label"));
33499 }
33500
33501 #[test]
33504 fn build_pdf_filename_slugifies_title() {
33505 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33506 assert!(
33507 name.starts_with("my_project_report_")
33508 && std::path::Path::new(&name)
33509 .extension()
33510 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33511 );
33512 }
33513
33514 #[test]
33515 fn build_pdf_filename_uses_last_run_id_segment() {
33516 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33517 assert!(name.contains("ABCD"), "must use last segment of run_id");
33518 }
33519
33520 #[test]
33521 fn build_pdf_filename_empty_title_uses_report_prefix() {
33522 let name = build_pdf_filename("", "abc-def-9999");
33523 assert!(
33524 name.starts_with("report_")
33525 && std::path::Path::new(&name)
33526 .extension()
33527 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33528 );
33529 }
33530
33531 #[test]
33534 fn swap_chart_js_replaces_inline_block() {
33535 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33536 let result = swap_inline_chart_js_for_static(html.to_string());
33537 assert!(result.contains(r#"src="/static/chart-report.js""#));
33538 assert!(!result.contains("inline source"));
33539 }
33540
33541 #[test]
33542 fn swap_chart_js_no_head_returns_unchanged() {
33543 let html = "<body>no head here</body>";
33544 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33545 }
33546
33547 #[test]
33548 fn swap_chart_js_no_script_in_head_unchanged() {
33549 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33550 let result = swap_inline_chart_js_for_static(html.to_string());
33551 assert!(!result.contains("chart-report.js"));
33552 }
33553
33554 #[test]
33557 fn patch_html_nonce_replaces_old_nonce() {
33558 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33559 let result = patch_html_nonce(html, "new-nonce-456");
33560 assert!(result.contains(r#"nonce="new-nonce-456""#));
33561 assert!(!result.contains("old-nonce-123"));
33562 }
33563
33564 #[test]
33565 fn patch_html_nonce_injects_into_bare_style() {
33566 let html = "<style>body{color:red;}</style>";
33567 let result = patch_html_nonce(html, "fresh-nonce");
33568 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33569 }
33570
33571 #[test]
33572 fn patch_html_nonce_injects_into_bare_script() {
33573 let html = "<script>console.log(1);</script>";
33574 let result = patch_html_nonce(html, "abc");
33575 assert!(result.contains(r#"<script nonce="abc">"#));
33576 }
33577
33578 #[test]
33581 fn is_html_report_file_result_html_matches() {
33582 let dir = tempfile::tempdir().unwrap();
33583 let path = dir.path().join("result_20240101.html");
33584 std::fs::write(&path, b"<html></html>").unwrap();
33585 assert!(is_html_report_file(&path));
33586 }
33587
33588 #[test]
33589 fn is_html_report_file_report_html_matches() {
33590 let dir = tempfile::tempdir().unwrap();
33591 let path = dir.path().join("report_abc.html");
33592 std::fs::write(&path, b"<html></html>").unwrap();
33593 assert!(is_html_report_file(&path));
33594 }
33595
33596 #[test]
33597 fn is_html_report_file_index_html_does_not_match() {
33598 let dir = tempfile::tempdir().unwrap();
33599 let path = dir.path().join("index.html");
33600 std::fs::write(&path, b"<html></html>").unwrap();
33601 assert!(!is_html_report_file(&path));
33602 }
33603
33604 #[test]
33605 fn is_html_report_file_nonexistent_returns_false() {
33606 assert!(!is_html_report_file(Path::new(
33607 "/nonexistent/result_xyz.html"
33608 )));
33609 }
33610
33611 #[test]
33612 fn find_html_report_in_dir_finds_result_html() {
33613 let dir = tempfile::tempdir().unwrap();
33614 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33615 assert!(find_html_report_in_dir(dir.path()).is_some());
33616 }
33617
33618 #[test]
33619 fn find_html_report_in_dir_empty_returns_none() {
33620 let dir = tempfile::tempdir().unwrap();
33621 assert!(find_html_report_in_dir(dir.path()).is_none());
33622 }
33623
33624 #[test]
33625 fn find_html_report_in_tree_finds_in_subdir() {
33626 let dir = tempfile::tempdir().unwrap();
33627 let subdir = dir.path().join("run-001");
33628 std::fs::create_dir_all(&subdir).unwrap();
33629 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33630 assert!(find_html_report_in_tree(dir.path()).is_some());
33631 }
33632
33633 #[test]
33636 fn derive_project_label_with_git_repo_and_ref() {
33637 let label = derive_project_label(
33638 Some("https://github.com/owner/my-repo.git"),
33639 Some("main"),
33640 "/fallback/path",
33641 );
33642 assert!(!label.is_empty(), "label must not be empty");
33643 assert!(
33644 label.contains("my") || label.contains("repo"),
33645 "got: {label}"
33646 );
33647 }
33648
33649 #[test]
33650 fn derive_project_label_fallback_to_path() {
33651 let label = derive_project_label(None, None, "/path/to/myproject");
33652 assert_eq!(label, "myproject");
33653 }
33654
33655 #[test]
33656 fn derive_project_label_empty_git_fields_use_path() {
33657 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33658 assert_eq!(label, "cool-app");
33659 }
33660
33661 #[test]
33664 fn derive_file_stem_with_commit_appends_sha() {
33665 assert_eq!(
33666 derive_file_stem("myproject", Some("a1b2c3")),
33667 "myproject_a1b2c3"
33668 );
33669 }
33670
33671 #[test]
33672 fn derive_file_stem_without_commit_returns_label() {
33673 assert_eq!(derive_file_stem("myproject", None), "myproject");
33674 }
33675
33676 #[test]
33677 fn derive_file_stem_empty_commit_returns_label() {
33678 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33679 }
33680
33681 #[test]
33684 fn split_patterns_none_is_empty() {
33685 assert!(split_patterns(None).is_empty());
33686 }
33687
33688 #[test]
33689 fn split_patterns_empty_string_is_empty() {
33690 assert!(split_patterns(Some("")).is_empty());
33691 }
33692
33693 #[test]
33694 fn split_patterns_comma_separated() {
33695 assert_eq!(
33696 split_patterns(Some("foo,bar,baz")),
33697 vec!["foo", "bar", "baz"]
33698 );
33699 }
33700
33701 #[test]
33702 fn split_patterns_newline_separated() {
33703 assert_eq!(
33704 split_patterns(Some("foo\nbar\nbaz")),
33705 vec!["foo", "bar", "baz"]
33706 );
33707 }
33708
33709 #[test]
33710 fn split_patterns_trims_whitespace() {
33711 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
33712 }
33713
33714 #[test]
33717 fn make_git_label_empty_repo_empty_result() {
33718 assert_eq!(make_git_label("", "main"), "");
33719 }
33720
33721 #[test]
33722 fn make_git_label_empty_ref_empty_result() {
33723 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33724 }
33725
33726 #[test]
33727 fn make_git_label_basic_format() {
33728 assert_eq!(
33729 make_git_label("https://github.com/owner/my-repo.git", "main"),
33730 "my-repo_at_main_sloc"
33731 );
33732 }
33733
33734 #[test]
33735 fn make_git_label_slash_in_ref_replaced() {
33736 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33737 assert!(
33738 !label.contains('/'),
33739 "slash in ref must be replaced: {label}"
33740 );
33741 }
33742
33743 #[test]
33746 fn format_dir_size_bytes() {
33747 assert_eq!(format_dir_size(500), "500 B");
33748 }
33749
33750 #[test]
33751 fn format_dir_size_kilobytes() {
33752 assert_eq!(format_dir_size(2048), "2 KB");
33753 }
33754
33755 #[test]
33756 fn format_dir_size_megabytes() {
33757 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33758 }
33759
33760 #[test]
33761 fn format_dir_size_gigabytes() {
33762 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33763 }
33764
33765 #[test]
33766 fn format_dir_size_zero() {
33767 assert_eq!(format_dir_size(0), "0 B");
33768 }
33769
33770 #[test]
33773 fn civil_from_days_epoch() {
33774 assert_eq!(civil_from_days(0), (1970, 1, 1));
33775 }
33776
33777 #[test]
33778 fn civil_from_days_one_year_later() {
33779 assert_eq!(civil_from_days(365), (1971, 1, 1));
33780 }
33781
33782 #[test]
33783 fn civil_from_days_31_days_is_feb_1_1970() {
33784 assert_eq!(civil_from_days(31), (1970, 2, 1));
33785 }
33786
33787 #[test]
33790 fn format_system_time_unix_epoch_formats_correctly() {
33791 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33792 }
33793
33794 #[test]
33795 fn format_system_time_31_days_after_epoch() {
33796 let t = UNIX_EPOCH + Duration::from_hours(744);
33797 assert_eq!(format_system_time(t), "1970-02-01 00:00");
33798 }
33799
33800 #[test]
33801 fn format_system_time_before_epoch_returns_dash() {
33802 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33803 assert_eq!(format_system_time(before), "-");
33804 }
33805 }
33806
33807 #[test]
33810 fn detect_language_name_dot_c() {
33811 assert_eq!(detect_language_name("main.c"), Some("C"));
33812 }
33813
33814 #[test]
33815 fn detect_language_name_dot_h() {
33816 assert_eq!(detect_language_name("defs.h"), Some("C"));
33817 }
33818
33819 #[test]
33820 fn detect_language_name_dot_cpp() {
33821 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33822 }
33823
33824 #[test]
33825 fn detect_language_name_dot_py() {
33826 assert_eq!(detect_language_name("script.py"), Some("Python"));
33827 }
33828
33829 #[test]
33830 fn detect_language_name_dot_ps1() {
33831 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33832 }
33833
33834 #[test]
33835 fn detect_language_name_dot_cs() {
33836 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33837 }
33838
33839 #[test]
33840 fn detect_language_name_dot_sh() {
33841 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33842 }
33843
33844 #[test]
33845 fn detect_language_name_unknown_txt() {
33846 assert_eq!(detect_language_name("notes.txt"), None);
33847 }
33848
33849 #[test]
33852 fn language_icon_file_c() {
33853 assert_eq!(language_icon_file("C"), Some("c.png"));
33854 }
33855
33856 #[test]
33857 fn language_icon_file_python() {
33858 assert_eq!(language_icon_file("Python"), Some("python.png"));
33859 }
33860
33861 #[test]
33862 fn language_icon_file_dockerfile() {
33863 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33864 }
33865
33866 #[test]
33867 fn language_icon_file_rust_is_none() {
33868 assert!(language_icon_file("Rust").is_none());
33869 }
33870
33871 #[test]
33872 fn language_icon_file_unknown_is_none() {
33873 assert!(language_icon_file("Fortran").is_none());
33874 }
33875
33876 #[test]
33879 fn language_inline_svg_rust_is_svg() {
33880 let svg = language_inline_svg("Rust").unwrap();
33881 assert!(svg.starts_with("<svg"));
33882 }
33883
33884 #[test]
33885 fn language_inline_svg_typescript_is_some() {
33886 assert!(language_inline_svg("TypeScript").is_some());
33887 }
33888
33889 #[test]
33890 fn language_inline_svg_unknown_is_none() {
33891 assert!(language_inline_svg("Fortran").is_none());
33892 }
33893
33894 #[test]
33897 fn classify_preview_file_c_supported() {
33898 assert!(matches!(
33899 classify_preview_file("main.c"),
33900 PreviewKind::Supported
33901 ));
33902 }
33903
33904 #[test]
33905 fn classify_preview_file_python_supported() {
33906 assert!(matches!(
33907 classify_preview_file("script.py"),
33908 PreviewKind::Supported
33909 ));
33910 }
33911
33912 #[test]
33913 fn classify_preview_file_png_skipped() {
33914 assert!(matches!(
33915 classify_preview_file("image.png"),
33916 PreviewKind::Skipped
33917 ));
33918 }
33919
33920 #[test]
33921 fn classify_preview_file_zip_skipped() {
33922 assert!(matches!(
33923 classify_preview_file("archive.zip"),
33924 PreviewKind::Skipped
33925 ));
33926 }
33927
33928 #[test]
33929 fn classify_preview_file_min_js_skipped() {
33930 assert!(matches!(
33931 classify_preview_file("bundle.min.js"),
33932 PreviewKind::Skipped
33933 ));
33934 }
33935
33936 #[test]
33937 fn classify_preview_file_rs_unsupported() {
33938 assert!(matches!(
33939 classify_preview_file("main.rs"),
33940 PreviewKind::Unsupported
33941 ));
33942 }
33943
33944 #[test]
33947 fn preview_relative_path_strips_root() {
33948 let root = PathBuf::from("/project");
33949 let path = PathBuf::from("/project/src/main.c");
33950 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33951 }
33952
33953 #[test]
33954 fn preview_relative_path_unrooted_includes_filename() {
33955 let root = PathBuf::from("/other");
33956 let path = PathBuf::from("/project/src/main.c");
33957 let result = preview_relative_path(&root, &path);
33958 assert!(result.contains("main.c"));
33959 }
33960
33961 #[test]
33962 fn preview_relative_path_uses_forward_slashes() {
33963 let root = PathBuf::from("/project");
33964 let path = PathBuf::from("/project/a/b/c.py");
33965 assert!(!preview_relative_path(&root, &path).contains('\\'));
33966 }
33967
33968 #[test]
33971 fn wildcard_match_exact_equal() {
33972 assert!(wildcard_match("foo", "foo"));
33973 }
33974
33975 #[test]
33976 fn wildcard_match_exact_mismatch() {
33977 assert!(!wildcard_match("foo", "bar"));
33978 }
33979
33980 #[test]
33981 fn wildcard_match_star_suffix() {
33982 assert!(wildcard_match("*.rs", "main.rs"));
33983 }
33984
33985 #[test]
33986 fn wildcard_match_star_middle_requires_suffix() {
33987 assert!(!wildcard_match("a*b", "ac"));
33988 }
33989
33990 #[test]
33991 fn wildcard_match_question_mark_single_char() {
33992 assert!(wildcard_match("f?o", "foo"));
33993 }
33994
33995 #[test]
33996 fn wildcard_match_double_star_nested() {
33997 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33998 }
33999
34000 #[test]
34001 fn wildcard_match_star_directory_entry() {
34002 assert!(wildcard_match("vendor/*", "vendor/crate"));
34003 }
34004
34005 #[test]
34006 fn wildcard_match_no_cross_prefix() {
34007 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34008 }
34009
34010 #[test]
34013 fn should_skip_empty_relative_is_false() {
34014 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34015 }
34016
34017 #[test]
34018 fn should_skip_matching_pattern() {
34019 assert!(should_skip_preview_directory(
34020 "vendor",
34021 &["vendor".to_string()]
34022 ));
34023 }
34024
34025 #[test]
34026 fn should_skip_non_matching() {
34027 assert!(!should_skip_preview_directory(
34028 "src",
34029 &["vendor".to_string()]
34030 ));
34031 }
34032
34033 #[test]
34034 fn should_skip_wildcard_prefix() {
34035 assert!(should_skip_preview_directory(
34036 "target/debug",
34037 &["target*".to_string()]
34038 ));
34039 }
34040
34041 #[test]
34044 fn should_include_empty_relative_always_true() {
34045 assert!(should_include_preview_file("", &[], &[]));
34046 }
34047
34048 #[test]
34049 fn should_include_no_patterns_includes_all() {
34050 assert!(should_include_preview_file("src/main.c", &[], &[]));
34051 }
34052
34053 #[test]
34054 fn should_include_excluded_by_pattern() {
34055 assert!(!should_include_preview_file(
34056 "vendor/lib.c",
34057 &[],
34058 &["vendor/*".to_string()]
34059 ));
34060 }
34061
34062 #[test]
34063 fn should_include_include_pattern_filters() {
34064 assert!(!should_include_preview_file(
34065 "tests/test_foo.c",
34066 &["src/*".to_string()],
34067 &[]
34068 ));
34069 }
34070
34071 #[test]
34074 fn escape_html_ampersand() {
34075 assert_eq!(escape_html("a&b"), "a&b");
34076 }
34077
34078 #[test]
34079 fn escape_html_angle_brackets() {
34080 assert_eq!(escape_html("<br>"), "<br>");
34081 }
34082
34083 #[test]
34084 fn escape_html_double_quote() {
34085 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34086 }
34087
34088 #[test]
34089 fn escape_html_single_quote() {
34090 assert_eq!(escape_html("it's"), "it's");
34091 }
34092
34093 #[test]
34094 fn escape_html_plain_text_unchanged() {
34095 assert_eq!(escape_html("hello world"), "hello world");
34096 }
34097
34098 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34101 sloc_core::ScanComparison {
34102 summary: sloc_core::SummaryDelta {
34103 baseline_run_id: "base".to_string(),
34104 current_run_id: "curr".to_string(),
34105 baseline_timestamp: chrono::Utc::now(),
34106 current_timestamp: chrono::Utc::now(),
34107 baseline_files: 4,
34108 current_files: 4,
34109 files_analyzed_delta: 0,
34110 baseline_code: 330,
34111 current_code: 400,
34112 code_lines_delta: 70,
34113 baseline_comments: 0,
34114 current_comments: 0,
34115 comment_lines_delta: 0,
34116 blank_lines_delta: 0,
34117 total_lines_delta: 70,
34118 coverage_lines_hit_delta: None,
34119 coverage_line_pct_delta: None,
34120 baseline_coverage_line_pct: None,
34121 current_coverage_line_pct: None,
34122 },
34123 file_deltas: vec![
34124 sloc_core::FileDelta {
34125 relative_path: "added.rs".to_string(),
34126 language: Some("Rust".to_string()),
34127 status: FileChangeStatus::Added,
34128 baseline_code: 0,
34129 current_code: 100,
34130 code_delta: 100,
34131 baseline_comment: 0,
34132 current_comment: 0,
34133 comment_delta: 0,
34134 baseline_blank: 0,
34135 current_blank: 0,
34136 blank_delta: 0,
34137 total_delta: 100,
34138 },
34139 sloc_core::FileDelta {
34140 relative_path: "removed.rs".to_string(),
34141 language: Some("Rust".to_string()),
34142 status: FileChangeStatus::Removed,
34143 baseline_code: 50,
34144 current_code: 0,
34145 code_delta: -50,
34146 baseline_comment: 0,
34147 current_comment: 0,
34148 comment_delta: 0,
34149 baseline_blank: 0,
34150 current_blank: 0,
34151 blank_delta: 0,
34152 total_delta: -50,
34153 },
34154 sloc_core::FileDelta {
34155 relative_path: "modified.rs".to_string(),
34156 language: Some("Rust".to_string()),
34157 status: FileChangeStatus::Modified,
34158 baseline_code: 80,
34159 current_code: 100,
34160 code_delta: 20,
34161 baseline_comment: 0,
34162 current_comment: 0,
34163 comment_delta: 0,
34164 baseline_blank: 0,
34165 current_blank: 0,
34166 blank_delta: 0,
34167 total_delta: 20,
34168 },
34169 sloc_core::FileDelta {
34170 relative_path: "unchanged.rs".to_string(),
34171 language: Some("Rust".to_string()),
34172 status: FileChangeStatus::Unchanged,
34173 baseline_code: 200,
34174 current_code: 200,
34175 code_delta: 0,
34176 baseline_comment: 0,
34177 current_comment: 0,
34178 comment_delta: 0,
34179 baseline_blank: 0,
34180 current_blank: 0,
34181 blank_delta: 0,
34182 total_delta: 0,
34183 },
34184 ],
34185 files_added: 1,
34186 files_removed: 1,
34187 files_modified: 1,
34188 files_unchanged: 1,
34189 }
34190 }
34191
34192 #[test]
34193 fn sum_added_counts_added_and_positive_modified() {
34194 let cmp = make_mixed_scan_comparison();
34195 assert_eq!(sum_added_code_lines(&cmp), 120);
34196 }
34197
34198 #[test]
34199 fn sum_removed_counts_removed_baseline() {
34200 let cmp = make_mixed_scan_comparison();
34201 assert_eq!(sum_removed_code_lines(&cmp), 50);
34202 }
34203
34204 #[test]
34205 fn sum_unmodified_counts_unchanged_files() {
34206 let cmp = make_mixed_scan_comparison();
34207 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34208 }
34209
34210 #[test]
34213 fn detect_coverage_tool_rust_project() {
34214 let dir = tempfile::tempdir().unwrap();
34215 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34216 let (tool, cmd) = detect_coverage_tool(dir.path());
34217 assert_eq!(tool, Some("cargo-llvm-cov"));
34218 assert!(cmd.is_some());
34219 }
34220
34221 #[test]
34222 fn detect_coverage_tool_java_gradle() {
34223 let dir = tempfile::tempdir().unwrap();
34224 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34225 let (tool, _) = detect_coverage_tool(dir.path());
34226 assert_eq!(tool, Some("jacoco"));
34227 }
34228
34229 #[test]
34230 fn detect_coverage_tool_python_pyproject() {
34231 let dir = tempfile::tempdir().unwrap();
34232 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34233 let (tool, _) = detect_coverage_tool(dir.path());
34234 assert_eq!(tool, Some("pytest-cov"));
34235 }
34236
34237 #[test]
34238 fn detect_coverage_tool_unknown_project() {
34239 let dir = tempfile::tempdir().unwrap();
34240 let (tool, cmd) = detect_coverage_tool(dir.path());
34241 assert!(tool.is_none() && cmd.is_none());
34242 }
34243
34244 #[test]
34247 fn sanitize_path_str_unc_drive_stripped() {
34248 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34249 }
34250
34251 #[test]
34252 fn sanitize_path_str_unc_network_stripped() {
34253 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34254 }
34255
34256 #[test]
34257 fn sanitize_path_str_plain_path_unchanged() {
34258 assert_eq!(
34259 sanitize_path_str("/home/user/project"),
34260 "/home/user/project"
34261 );
34262 }
34263
34264 #[test]
34265 fn display_path_plain_linux_unchanged() {
34266 assert_eq!(
34267 display_path(Path::new("/home/user/project")),
34268 "/home/user/project"
34269 );
34270 }
34271
34272 #[test]
34273 fn display_path_unc_drive_stripped() {
34274 let result = display_path(Path::new(r"\\?\C:\Users\user"));
34275 assert_eq!(result, r"C:\Users\user");
34276 }
34277
34278 #[test]
34279 fn display_path_unc_network_stripped() {
34280 let result = display_path(Path::new(r"\\?\UNC\server\share"));
34281 assert_eq!(result, r"\\server\share");
34282 }
34283}
34284
34285#[cfg(test)]
34286mod coverage_boost_unit_tests {
34287 use super::*;
34288 use std::path::{Path, PathBuf};
34289
34290 #[tokio::test]
34294 async fn runtime_security_config_scenarios() {
34295 std::env::remove_var("SLOC_API_KEYS");
34296 std::env::remove_var("SLOC_API_KEY");
34297 std::env::remove_var("SLOC_TLS_CERT");
34298 std::env::remove_var("SLOC_TLS_KEY");
34299 std::env::remove_var("SLOC_TRUST_PROXY");
34300 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34301 let cfg = load_runtime_security_config(false);
34302 assert!(cfg.api_keys.is_empty());
34303 assert!(!cfg.tls_enabled);
34304 assert!(!cfg.trust_proxy);
34305
34306 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34307 std::env::set_var("SLOC_TRUST_PROXY", "1");
34308 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34309 std::env::set_var("SLOC_RATE_LIMIT", "250");
34310 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34311 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34312 let cfg = load_runtime_security_config(true);
34313 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34314 assert!(cfg.trust_proxy);
34315 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34316 std::env::remove_var("SLOC_API_KEYS");
34317 std::env::remove_var("SLOC_TRUST_PROXY");
34318 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34319 std::env::remove_var("SLOC_RATE_LIMIT");
34320 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34321 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34322 }
34323
34324 #[test]
34325 fn cors_layer_builds_both_modes() {
34326 let _ = build_cors_layer(true);
34327 let _ = build_cors_layer(false);
34328 }
34329
34330 #[test]
34331 fn primary_lan_ip_callable() {
34332 let _ = primary_lan_ip();
34334 }
34335
34336 #[test]
34337 fn safe_redirect_allows_relative_rejects_absolute() {
34338 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34339 assert_eq!(safe_redirect("https://evil.example/x"), "/");
34340 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34341 assert_eq!(default_redirect(), "/view-reports");
34342 }
34343
34344 #[test]
34345 fn tarball_size_caps_env_override() {
34346 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34347 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34348 let (c, d) = parse_tarball_size_caps();
34349 assert_eq!(c, 1024 * 1024);
34350 assert_eq!(d, 2 * 1024 * 1024);
34351 std::env::remove_var("SLOC_MAX_TARBALL_MB");
34352 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34353 let (c2, _) = parse_tarball_size_caps();
34354 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34355 }
34356
34357 #[test]
34358 fn upload_path_helpers() {
34359 let base = upload_base_dir();
34360 let staged = upload_staging_path("abc123");
34361 assert!(staged.starts_with(&base));
34362 assert!(
34363 is_upload_tmp_path(&staged),
34364 "staging path is an upload tmp path"
34365 );
34366 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34367 }
34368
34369 #[test]
34370 fn git_clones_dir_env_override() {
34371 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34372 let def = resolve_git_clones_dir(Path::new("/out"));
34373 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34374 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34375 assert_eq!(
34376 resolve_git_clones_dir(Path::new("/out")),
34377 PathBuf::from("/custom/clones")
34378 );
34379 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34380 }
34381
34382 #[test]
34383 fn html_report_file_detection() {
34384 let dir = std::env::temp_dir().join("sloc_html_detect");
34385 let _ = std::fs::create_dir_all(&dir);
34386 let good = dir.join("report_x.html");
34387 std::fs::write(&good, "<html></html>").unwrap();
34388 let bad = dir.join("notes.txt");
34389 std::fs::write(&bad, "x").unwrap();
34390 assert!(is_html_report_file(&good));
34391 assert!(!is_html_report_file(&bad));
34392 assert!(find_html_report_in_dir(&dir).is_some());
34393 let _ = std::fs::remove_dir_all(&dir);
34394 }
34395
34396 #[test]
34397 fn multi_delta_class_and_format() {
34398 assert_eq!(multi_delta_class(5), "pos");
34399 assert_eq!(multi_delta_class(-5), "neg");
34400 assert_eq!(multi_delta_class(0), "zero");
34401 assert_eq!(multi_fmt_delta(3), "+3");
34402 assert_eq!(multi_fmt_delta(-3), "-3");
34403 assert_eq!(multi_fmt_delta(0), "0");
34404 }
34405
34406 #[test]
34407 fn git_clone_dest_sanitizes() {
34408 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34409 assert!(dest.starts_with("/clones"));
34410 let name = dest.file_name().unwrap().to_str().unwrap();
34411 assert!(name
34412 .chars()
34413 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34414 }
34415}
34416
34417#[cfg(test)]
34418mod tests_private {
34419 use super::*;
34420 use std::io::Read;
34421
34422 #[test]
34425 fn local_mode_never_refuses_start() {
34426 assert!(!refuse_unauthenticated_server(false, false));
34428 assert!(!refuse_unauthenticated_server(false, true));
34429 }
34430
34431 #[test]
34432 fn server_mode_with_key_is_allowed() {
34433 assert!(!refuse_unauthenticated_server(true, true));
34434 }
34435
34436 #[test]
34439 fn server_mode_auth_gate_respects_optin() {
34440 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34441 assert!(
34442 refuse_unauthenticated_server(true, false),
34443 "server mode + no key must fail closed by default"
34444 );
34445 std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34446 assert!(
34447 !refuse_unauthenticated_server(true, false),
34448 "explicit opt-in must allow the unauthenticated server"
34449 );
34450 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34451 }
34452
34453 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34459 let mut h = [0u8; 512];
34460 let nb = name.as_bytes();
34461 h[..nb.len()].copy_from_slice(nb);
34462 h[100..108].copy_from_slice(b"0000644\0");
34463 h[108..116].copy_from_slice(b"0000000\0");
34464 h[116..124].copy_from_slice(b"0000000\0");
34465 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34466 h[136..148].copy_from_slice(b"00000000000\0");
34467 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
34469 h[263..265].copy_from_slice(b"00");
34470 for b in &mut h[148..156] {
34471 *b = b' ';
34472 }
34473 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34474 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34475
34476 let mut out = h.to_vec();
34477 out.extend_from_slice(data);
34478 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
34481 }
34482
34483 #[tokio::test]
34487 async fn tarball_extraction_blocks_zip_slip() {
34488 use std::io::Write as _;
34489
34490 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34491 let staging = base.join("staging");
34492 let tar_gz = base.join("evil.tar.gz");
34493 std::fs::create_dir_all(&base).unwrap();
34494
34495 {
34497 let f = std::fs::File::create(&tar_gz).unwrap();
34498 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34499 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34500 .unwrap();
34501 enc.finish().unwrap().flush().unwrap();
34502 }
34503
34504 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34506
34507 let escaped = base.join("escaped.txt");
34508 assert!(
34509 !escaped.exists(),
34510 "zip-slip entry escaped staging to {}",
34511 escaped.display()
34512 );
34513
34514 let _ = std::fs::remove_dir_all(&base);
34515 }
34516
34517 #[test]
34518 fn size_limit_reader_zero_remaining_returns_error() {
34519 let data = b"hello world";
34520 let mut reader = SizeLimitReader {
34521 inner: &data[..],
34522 remaining: 0,
34523 };
34524 let mut buf = [0u8; 4];
34525 assert!(reader.read(&mut buf).is_err());
34526 }
34527
34528 #[test]
34529 fn size_limit_reader_counts_bytes() {
34530 let data = b"hello world";
34531 let mut reader = SizeLimitReader {
34532 inner: &data[..],
34533 remaining: 5,
34534 };
34535 let mut buf = [0u8; 4];
34536 let n = reader.read(&mut buf).unwrap();
34537 assert_eq!(n, 4);
34538 assert_eq!(reader.remaining, 1);
34539 }
34540
34541 #[test]
34542 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34543 let uuid = "12345678-1234-1234-1234-123456789012";
34544 let (id, path) = resolve_or_create_staging(Some(uuid));
34545 assert_eq!(id, uuid);
34546 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34547 }
34548
34549 #[test]
34550 fn resolve_or_create_staging_with_none_creates_new() {
34551 let (id1, _) = resolve_or_create_staging(None);
34552 let (id2, _) = resolve_or_create_staging(None);
34553 assert_ne!(id1, id2);
34554 }
34555
34556 #[test]
34557 fn resolve_or_create_staging_with_path_separator_creates_new() {
34558 let (id, _) = resolve_or_create_staging(Some("has/slash"));
34560 assert_ne!(id, "has/slash");
34561 }
34562
34563 #[test]
34564 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34565 use std::net::IpAddr;
34566 use std::str::FromStr;
34567 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34568 let ip = IpAddr::from_str("192.168.1.1").unwrap();
34569 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34570 }
34571
34572 #[test]
34573 fn is_auth_locked_out_expired_entry_removed() {
34574 use std::net::IpAddr;
34575 use std::str::FromStr;
34576 let limiter = IpRateLimiter::new(
34577 Duration::from_mins(1),
34578 100,
34579 1, Duration::from_millis(1),
34581 );
34582 let ip = IpAddr::from_str("192.168.1.2").unwrap();
34583 limiter.record_auth_failure(ip);
34584 std::thread::sleep(Duration::from_millis(10));
34586 assert!(!limiter.is_auth_locked_out(ip));
34588 }
34589
34590 #[test]
34591 fn is_auth_locked_out_within_window_returns_true() {
34592 use std::net::IpAddr;
34593 use std::str::FromStr;
34594 let limiter = IpRateLimiter::new(
34595 Duration::from_mins(1),
34596 100,
34597 2, Duration::from_hours(1),
34599 );
34600 let ip = IpAddr::from_str("192.168.1.3").unwrap();
34601 limiter.record_auth_failure(ip);
34602 limiter.record_auth_failure(ip);
34603 assert!(limiter.is_auth_locked_out(ip));
34604 }
34605
34606 #[test]
34609 fn output_folder_hint_strips_json_subdir() {
34610 use std::path::Path;
34611 let path = Path::new("/output/scan1/json/result.json");
34612 let hint = output_folder_hint(path);
34613 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34614 }
34615
34616 #[test]
34617 fn output_folder_hint_strips_html_subdir() {
34618 use std::path::Path;
34619 let path = Path::new("/output/scan1/html/report.html");
34620 let hint = output_folder_hint(path);
34621 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34622 }
34623
34624 #[test]
34625 fn output_folder_hint_strips_pdf_subdir() {
34626 use std::path::Path;
34627 let path = Path::new("/output/scan1/pdf/report.pdf");
34628 let hint = output_folder_hint(path);
34629 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34630 }
34631
34632 #[test]
34633 fn output_folder_hint_strips_excel_subdir() {
34634 use std::path::Path;
34635 let path = Path::new("/output/scan1/excel/report.xlsx");
34636 let hint = output_folder_hint(path);
34637 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34638 }
34639
34640 #[test]
34641 fn output_folder_hint_flat_layout_returns_direct_parent() {
34642 use std::path::Path;
34643 let path = Path::new("/output/scan1/result.json");
34644 let hint = output_folder_hint(path);
34645 assert!(
34646 hint.ends_with("scan1"),
34647 "expected direct parent, got: {hint}"
34648 );
34649 }
34650
34651 #[test]
34652 fn output_folder_hint_other_subdir_name_not_stripped() {
34653 use std::path::Path;
34654 let path = Path::new("/output/scan1/data/result.json");
34656 let hint = output_folder_hint(path);
34657 assert!(
34658 hint.ends_with("data"),
34659 "non-artifact subdir must not be stripped, got: {hint}"
34660 );
34661 }
34662
34663 #[test]
34666 fn find_file_by_ext_finds_matching_file() {
34667 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34668 let _ = fs::create_dir_all(&dir);
34669 let f = dir.join("report.pdf");
34670 let _ = fs::write(&f, b"dummy");
34671 let result = find_file_by_ext(&dir, "pdf");
34672 assert!(result.is_some(), "expected to find report.pdf");
34673 let _ = fs::remove_dir_all(&dir);
34674 }
34675
34676 #[test]
34677 fn find_file_by_ext_returns_none_for_missing_ext() {
34678 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34679 let _ = fs::create_dir_all(&dir);
34680 let f = dir.join("report.json");
34681 let _ = fs::write(&f, b"{}");
34682 let result = find_file_by_ext(&dir, "pdf");
34683 assert!(result.is_none());
34684 let _ = fs::remove_dir_all(&dir);
34685 }
34686
34687 #[test]
34688 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34689 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34690 assert!(find_file_by_ext(dir, "json").is_none());
34691 }
34692
34693 #[test]
34696 fn collect_result_json_candidates_flat_root() {
34697 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34698 let _ = fs::create_dir_all(&root);
34699 let _ = fs::write(root.join("result.json"), b"{}");
34700 let candidates = collect_result_json_candidates(&root);
34701 assert!(!candidates.is_empty(), "should find result.json at root");
34702 let _ = fs::remove_dir_all(&root);
34703 }
34704
34705 #[test]
34706 fn collect_result_json_candidates_legacy_subdir() {
34707 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34708 let sub = root.join("scanA");
34709 let _ = fs::create_dir_all(&sub);
34710 let _ = fs::write(sub.join("result.json"), b"{}");
34711 let candidates = collect_result_json_candidates(&root);
34712 assert!(
34713 !candidates.is_empty(),
34714 "should find result.json in legacy subdir"
34715 );
34716 let _ = fs::remove_dir_all(&root);
34717 }
34718
34719 #[test]
34720 fn collect_result_json_candidates_structured_json_subdir() {
34721 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34722 let json_sub = root.join("scanB").join("json");
34723 let _ = fs::create_dir_all(&json_sub);
34724 let _ = fs::write(json_sub.join("result.json"), b"{}");
34725 let candidates = collect_result_json_candidates(&root);
34726 assert!(
34727 !candidates.is_empty(),
34728 "should find result.json inside <subdir>/json/"
34729 );
34730 let _ = fs::remove_dir_all(&root);
34731 }
34732
34733 #[test]
34734 fn collect_result_json_candidates_empty_dir() {
34735 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34736 let _ = fs::create_dir_all(&root);
34737 let candidates = collect_result_json_candidates(&root);
34738 assert!(candidates.is_empty());
34739 let _ = fs::remove_dir_all(&root);
34740 }
34741}