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 auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33 collections::{HashMap, VecDeque},
34 fmt::Write,
35 fs,
36 net::{IpAddr, SocketAddr},
37 path::{Path, PathBuf},
38 process::Stdio,
39 sync::{Arc, OnceLock},
40 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46 body::Body,
47 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48 http::{header, HeaderValue, Request, StatusCode},
49 middleware::{self, Next},
50 response::{Html, IntoResponse, Response},
51 routing::{get, post},
52 Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
59 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
60 MixedLinePolicy,
61};
62use sloc_git::ScheduleStore;
63
64#[derive(Clone)]
65pub(crate) struct CspNonce(pub(crate) String);
66
67static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
68static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
69
70use sloc_core::{
71 analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
72 CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
73 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
74};
75use sloc_report::{
76 render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
77 write_pdf_from_run, ReportDeltaContext,
78};
79const MAX_CONCURRENT_ANALYSES: usize = 4;
80
81#[cfg(target_os = "windows")]
89#[allow(clippy::upper_case_acronyms)]
90#[allow(dead_code)]
91mod win_dialog_focus {
92 #[cfg(feature = "native-dialog")]
93 use std::mem::size_of;
94
95 type HWND = *mut core::ffi::c_void;
96 type DWORD = u32;
97 type UINT = u32;
98 type BOOL = i32;
99
100 #[cfg(feature = "native-dialog")]
102 #[repr(C)]
103 #[allow(non_snake_case)]
104 struct FLASHWINFO {
105 cbSize: UINT,
106 hwnd: HWND,
107 dwFlags: DWORD,
108 uCount: UINT,
109 dwTimeout: DWORD,
110 }
111
112 #[cfg(feature = "native-dialog")]
113 const FLASHW_ALL: DWORD = 0x3;
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_TIMERNOFG: DWORD = 0xC;
116
117 #[link(name = "user32")]
118 extern "system" {
119 fn GetForegroundWindow() -> HWND;
120 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
121 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
122 fn BringWindowToTop(hWnd: HWND) -> BOOL;
123 fn SetWindowPos(
124 hWnd: HWND,
125 hWndAfter: HWND,
126 x: i32,
127 y: i32,
128 cx: i32,
129 cy: i32,
130 flags: UINT,
131 ) -> BOOL;
132 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
133 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
134 #[cfg(feature = "native-dialog")]
135 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
136 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
137 fn FindWindowExW(
138 hWndParent: HWND,
139 hWndChildAfter: HWND,
140 lpszClass: *const u16,
141 lpszWindow: *const u16,
142 ) -> HWND;
143 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
147 }
148
149 #[link(name = "kernel32")]
150 extern "system" {
151 #[cfg(feature = "native-dialog")]
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 ShowWindow(hwnd, 9);
249 SwitchToThisWindow(hwnd, 1);
250 SetForegroundWindow(hwnd);
251 BringWindowToTop(hwnd);
252 }
253
254 pub fn open_folder_foreground(path: std::path::PathBuf) {
261 std::thread::spawn(move || {
262 use std::os::windows::ffi::OsStrExt;
263
264 let op: Vec<u16> = "explore\0".encode_utf16().collect();
265 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
266 path_w.push(0);
267 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
268
269 unsafe {
270 let existing = snapshot_explorer_hwnds(&class_w);
273 let fg_hwnd = GetForegroundWindow();
274 ShellExecuteW(
276 fg_hwnd,
277 op.as_ptr(),
278 path_w.as_ptr(),
279 core::ptr::null(),
280 core::ptr::null(),
281 1,
282 );
283
284 for _ in 0..40 {
288 std::thread::sleep(std::time::Duration::from_millis(75));
289 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
290 bring_to_front(w);
291 return;
292 }
293 }
294
295 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
298 if !w.is_null() {
299 bring_to_front(w);
300 }
301 }
302 });
303 }
304
305 #[cfg(feature = "native-dialog")]
309 pub fn flash_dialog_when_ready(title: String) {
310 std::thread::spawn(move || {
311 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
312 for _ in 0..40 {
313 std::thread::sleep(std::time::Duration::from_millis(80));
314 unsafe {
315 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
316 if !hwnd.is_null() {
317 SetForegroundWindow(hwnd);
318 BringWindowToTop(hwnd);
319 #[allow(non_snake_case)]
320 FlashWindowEx(&FLASHWINFO {
321 #[allow(clippy::cast_possible_truncation)]
324 cbSize: size_of::<FLASHWINFO>() as UINT,
325 hwnd,
326 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
327 uCount: 3,
328 dwTimeout: 0,
329 });
330 break;
331 }
332 }
333 }
334 });
335 }
336}
337
338pub(crate) struct IpRateLimiter {
341 window: Duration,
342 max_requests: usize,
343 pub(crate) auth_lockout_threshold: u32,
344 auth_lockout_window: Duration,
345 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
346 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
347}
348
349impl IpRateLimiter {
350 pub(crate) fn new(
351 window: Duration,
352 max_requests: usize,
353 auth_lockout_threshold: u32,
354 auth_lockout_window: Duration,
355 ) -> Self {
356 Self {
357 window,
358 max_requests,
359 auth_lockout_threshold,
360 auth_lockout_window,
361 state: std::sync::Mutex::new(HashMap::new()),
362 auth_failures: std::sync::Mutex::new(HashMap::new()),
363 }
364 }
365
366 #[allow(clippy::significant_drop_tightening)]
369 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
370 let now = Instant::now();
371 let cutoff = now.checked_sub(self.window).unwrap_or(now);
372 let mut state = self
373 .state
374 .lock()
375 .unwrap_or_else(std::sync::PoisonError::into_inner);
376 if state.len() > 10_000 {
377 state.retain(|_, bucket| {
378 while bucket.front().is_some_and(|t| *t <= cutoff) {
379 bucket.pop_front();
380 }
381 !bucket.is_empty()
382 });
383 }
384 let bucket = state.entry(ip).or_default();
385 while bucket.front().is_some_and(|t| *t <= cutoff) {
386 bucket.pop_front();
387 }
388 if bucket.len() >= self.max_requests {
389 false
390 } else {
391 bucket.push_back(now);
392 true
393 }
394 }
395
396 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
397 let now = Instant::now();
398 let mut map = self
399 .auth_failures
400 .lock()
401 .unwrap_or_else(std::sync::PoisonError::into_inner);
402 map.entry(ip)
403 .and_modify(|e| {
404 e.0 += 1;
405 e.1 = now;
406 })
407 .or_insert_with(|| (1, now));
408 }
409
410 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
411 let mut map = self
412 .auth_failures
413 .lock()
414 .unwrap_or_else(std::sync::PoisonError::into_inner);
415 let expired = map
416 .get(&ip)
417 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
418 if expired {
419 map.remove(&ip);
420 return false;
421 }
422 map.get(&ip)
423 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
424 }
425
426 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
427 let map = self
428 .auth_failures
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 map.get(&ip).map_or(0, |e| {
432 self.auth_lockout_window
433 .checked_sub(e.1.elapsed())
434 .map_or(0, |r| r.as_secs())
435 })
436 }
437
438 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
439 tokio::spawn(async move {
440 let mut interval = tokio::time::interval(Duration::from_mins(1));
441 interval.tick().await; loop {
443 interval.tick().await;
444 let now = Instant::now();
445 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
446 {
447 let mut state = limiter
448 .state
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 state.retain(|_, bucket| {
452 while bucket.front().is_some_and(|t| *t <= cutoff) {
453 bucket.pop_front();
454 }
455 !bucket.is_empty()
456 });
457 }
458 {
459 let mut auth = limiter
460 .auth_failures
461 .lock()
462 .unwrap_or_else(std::sync::PoisonError::into_inner);
463 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
464 }
465 }
466 });
467 }
468}
469
470fn spawn_upload_staging_cleanup() {
474 tokio::spawn(async move {
475 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
476 .ok()
477 .and_then(|v| v.parse().ok())
478 .unwrap_or(4);
479 let ttl_secs = ttl_hours * 3600;
480 let mut interval = tokio::time::interval(Duration::from_hours(1));
481 interval.tick().await; loop {
483 interval.tick().await;
484 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
485 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
486 continue;
487 };
488 while let Ok(Some(entry)) = dir.next_entry().await {
489 let path = entry.path();
490 let age_secs = tokio::fs::metadata(&path)
491 .await
492 .ok()
493 .and_then(|m| m.modified().ok())
494 .and_then(|t| t.elapsed().ok())
495 .map_or(0, |d| d.as_secs());
496 if age_secs > ttl_secs {
497 tracing::debug!(
498 event = "upload_staging_cleanup",
499 path = %path.display(),
500 age_secs,
501 "removing stale upload staging directory"
502 );
503 let _ = tokio::fs::remove_dir_all(&path).await;
504 }
505 }
506 }
507 });
508}
509
510#[derive(Clone, Debug, Default)]
512struct RunResultContext {
513 prev_entry: Option<RegistryEntry>,
514 prev_scan_count: usize,
515 project_path: String,
516 cocomo_mode: String,
518 complexity_alert: u32,
520 #[allow(dead_code)]
522 exclude_duplicates: bool,
523}
524
525#[derive(Clone)]
527enum AsyncRunState {
528 Running {
529 started_at: std::time::Instant,
530 cancel_token: Arc<std::sync::atomic::AtomicBool>,
531 phase: Arc<std::sync::Mutex<String>>,
532 files_done: Arc<std::sync::atomic::AtomicUsize>,
533 files_total: Arc<std::sync::atomic::AtomicUsize>,
534 },
535 Complete {
537 run_id: String,
538 },
539 Failed {
540 message: String,
541 },
542 Cancelled,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
548struct ScanProfile {
549 id: String,
550 name: String,
551 created_at: String,
552 params: serde_json::Value,
554}
555
556#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557struct ScanProfileStore {
558 profiles: Vec<ScanProfile>,
559}
560
561impl ScanProfileStore {
562 fn load(path: &std::path::Path) -> Self {
563 fs::read_to_string(path)
564 .ok()
565 .and_then(|s| serde_json::from_str(&s).ok())
566 .unwrap_or_default()
567 }
568
569 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
570 if let Some(parent) = path.parent() {
571 fs::create_dir_all(parent)?;
572 }
573 let json = serde_json::to_string_pretty(self)?;
574 fs::write(path, json)?;
575 Ok(())
576 }
577}
578
579#[derive(Clone)]
580pub(crate) struct AppState {
581 pub(crate) base_config: AppConfig,
582 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
583 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
584 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
585 pub(crate) registry_path: PathBuf,
586 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
587 pub(crate) server_mode: bool,
588 pub(crate) tls_enabled: bool,
589 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
590 pub(crate) rate_limiter: Arc<IpRateLimiter>,
591 pub(crate) trust_proxy: bool,
592 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
595 pub(crate) git_clones_dir: PathBuf,
597 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
599 pub(crate) schedules_path: PathBuf,
600 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
602 pub(crate) scan_profiles_path: PathBuf,
603 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
604 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
606 pub(crate) confluence_path: PathBuf,
607 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
609 pub(crate) watched_dirs_path: PathBuf,
610 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
612 pub(crate) cleanup_policy_path: PathBuf,
613 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
615}
616
617type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
618
619#[derive(Clone, Debug)]
622pub(crate) struct RunArtifacts {
623 output_dir: PathBuf,
624 html_path: Option<PathBuf>,
625 pdf_path: Option<PathBuf>,
626 json_path: Option<PathBuf>,
627 csv_path: Option<PathBuf>,
628 xlsx_path: Option<PathBuf>,
629 scan_config_path: Option<PathBuf>,
630 report_title: String,
631 result_context: RunResultContext,
632}
633
634#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
636 let protected = Router::new()
637 .route("/", get(splash))
638 .route("/scan-setup", get(scan_setup_handler))
639 .route("/scan", get(index))
640 .route("/analyze", post(analyze_handler))
641 .route("/preview", get(preview_handler))
642 .route("/api/suggest-coverage", get(api_suggest_coverage))
643 .route("/pick-directory", get(pick_directory_handler))
644 .route("/open-path", get(open_path_handler))
645 .route("/pick-file", get(pick_file_handler))
646 .route(
647 "/api/upload-directory",
648 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
649 )
650 .route(
651 "/api/upload-file",
652 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
653 )
654 .route(
655 "/api/upload-tarball",
656 post(upload_tarball_handler)
659 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
660 )
661 .route("/locate-report", post(locate_report_handler))
662 .route("/locate-reports-dir", post(locate_reports_dir_handler))
663 .route("/relocate-scan", post(relocate_scan_handler))
664 .route("/watched-dirs/add", post(add_watched_dir_handler))
665 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
666 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
667 .route("/view-reports", get(history_handler))
668 .route("/compare-scans", get(compare_select_handler))
669 .route("/compare", get(compare_handler))
670 .route("/multi-compare", get(multi_compare_handler))
671 .route("/images/{folder}/{file}", get(image_handler))
672 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
673 .route("/api/metrics/latest", get(api_metrics_latest_handler))
674 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
675 .route("/api/metrics/history", get(api_metrics_history_handler))
676 .route("/api/metrics/churn", get(api_metrics_churn_handler))
677 .route(
678 "/api/metrics/submodules",
679 get(api_metrics_submodules_handler),
680 )
681 .route("/api/ingest", post(api_ingest_handler))
682 .route("/api/project-history", get(project_history_handler))
683 .route("/trend-reports", get(trend_report_handler))
684 .route("/test-metrics", get(test_metrics_handler))
685 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
686 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
687 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
688 .route("/runs/result/{run_id}", get(async_run_result_handler))
689 .route("/embed/summary", get(embed_handler))
690 .route("/git-browser", get(git_browser::git_browser_handler))
692 .route("/api/git/refs", get(git_browser::api_list_refs))
693 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
694 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
695 .route(
700 "/export/pdf",
701 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
702 )
703 .route("/export-config", get(export_config_handler))
705 .route("/import-config", post(import_config_handler))
706 .route("/api/scan-profiles", get(api_list_scan_profiles))
708 .route("/api/scan-profiles", post(api_save_scan_profile))
709 .route(
710 "/api/scan-profiles/{id}",
711 axum::routing::delete(api_delete_scan_profile),
712 )
713 .route("/integrations", get(integrations::integrations_handler))
715 .route(
716 "/webhook-setup",
717 get(|| async { axum::response::Redirect::permanent("/integrations") }),
718 )
719 .route(
720 "/confluence-setup",
721 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
722 )
723 .route("/api/schedules", get(git_webhook::api_list_schedules))
724 .route("/api/schedules", post(git_webhook::api_create_schedule))
725 .route(
726 "/api/schedules",
727 axum::routing::delete(git_webhook::api_delete_schedule),
728 )
729 .route(
730 "/api/confluence/config",
731 get(confluence::api_get_confluence_config),
732 )
733 .route(
734 "/api/confluence/config",
735 post(confluence::api_save_confluence_config),
736 )
737 .route(
738 "/api/confluence/test",
739 post(confluence::api_test_confluence),
740 )
741 .route(
742 "/api/confluence/post",
743 post(confluence::api_post_to_confluence),
744 )
745 .route(
746 "/api/confluence/wiki-markup",
747 get(confluence::api_wiki_markup),
748 )
749 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
751 .route(
752 "/api/runs/{run_id}",
753 axum::routing::delete(delete_run_handler),
754 )
755 .route("/api/runs/cleanup", post(cleanup_runs_handler))
756 .route(
758 "/api/cleanup-policy",
759 get(api_get_cleanup_policy)
760 .post(api_save_cleanup_policy)
761 .delete(api_delete_cleanup_policy),
762 )
763 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
764 .route("/api-docs", get(api_docs_handler))
766 .route("/metrics", get(metrics_handler))
768 .route_layer(middleware::from_fn_with_state(
769 state.clone(),
770 auth::require_api_key,
771 ));
772
773 protected
774 .route("/healthz", get(healthz))
775 .route("/api/health", get(healthz))
776 .route("/api/version", get(api_version_handler))
777 .route("/api/openapi.yaml", get(openapi_yaml_handler))
778 .route("/llms.txt", get(llms_txt_handler))
779 .route("/llms-full.txt", get(llms_full_txt_handler))
780 .route("/badge/{metric}", get(badge_handler))
781 .route("/static/chart.js", get(chart_js_handler))
782 .route("/static/chart-report.js", get(report_chart_js_handler))
783 .route("/auth/login", get(auth::auth_login_get))
784 .route("/auth/login", post(auth::auth_login_post))
785 .route("/auth/logout", post(auth::auth_logout))
786 .route(
789 "/webhooks/github",
790 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
791 )
792 .route(
793 "/webhooks/gitlab",
794 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
795 )
796 .route(
797 "/webhooks/bitbucket",
798 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
799 )
800 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
801 .layer(middleware::from_fn(csrf_protect))
802 .layer(middleware::from_fn_with_state(
803 state.clone(),
804 add_security_headers,
805 ))
806 .layer(build_cors_layer(state.server_mode))
807 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
808 .with_state(state)
809}
810
811pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
814
815fn test_app_state(tmp_subdir: &str) -> AppState {
821 std::env::set_var("SLOC_HEADLESS", "1");
822 let tmp = std::env::temp_dir().join(tmp_subdir);
823 AppState {
824 base_config: AppConfig::default(),
825 artifacts: Arc::new(Mutex::new(HashMap::new())),
826 async_runs: Arc::new(Mutex::new(HashMap::new())),
827 registry: Arc::new(Mutex::new(ScanRegistry::default())),
828 registry_path: tmp.join("registry.json"),
829 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
830 server_mode: false,
831 tls_enabled: false,
832 api_keys: Arc::new(vec![]),
833 rate_limiter: Arc::new(IpRateLimiter::new(
834 Duration::from_mins(1),
835 600,
836 10,
837 Duration::from_hours(1),
838 )),
839 trust_proxy: false,
840 trusted_proxy_ips: vec![],
841 git_clones_dir: tmp.join("git-clones"),
842 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
843 schedules_path: tmp.join("schedules.json"),
844 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
845 scan_profiles_path: tmp.join("scan_profiles.json"),
846 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
847 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
848 confluence_path: tmp.join("confluence_config.json"),
849 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
850 watched_dirs_path: tmp.join("watched_dirs.json"),
851 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
852 cleanup_policy_path: tmp.join("cleanup_policy.json"),
853 cleanup_task_handle: Arc::new(Mutex::new(None)),
854 }
855}
856
857pub fn make_test_router() -> Router {
859 build_router(test_app_state("sloc_test"))
860}
861
862pub fn make_test_router_with_key(api_key: &str) -> Router {
864 let mut state = test_app_state("sloc_test_key");
865 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
866 build_router(state)
867}
868
869pub fn make_test_router_server_mode() -> Router {
873 let mut state = test_app_state("sloc_test_server");
874 state.server_mode = true;
875 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
876 TEST_SERVER_MODE_API_KEY.to_owned(),
877 ))]);
878 build_router(state)
879}
880
881pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
885 let mut state = test_app_state("sloc_test_server_roots");
886 state.server_mode = true;
887 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
888 TEST_SERVER_MODE_API_KEY.to_owned(),
889 ))]);
890 state.base_config.discovery.allowed_scan_roots = roots;
891 build_router(state)
892}
893
894pub fn make_test_router_exhausted_semaphore() -> Router {
897 let mut state = test_app_state("sloc_test_exhaust");
898 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
899 build_router(state)
900}
901
902pub fn make_test_router_tight_rate_limit() -> Router {
905 let mut state = test_app_state("sloc_test_rate");
906 state.rate_limiter = Arc::new(IpRateLimiter::new(
907 Duration::from_mins(1),
908 2,
909 5,
910 Duration::from_secs(5),
911 ));
912 build_router(state)
913}
914
915pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
918 let mut state = test_app_state("sloc_test_auth_lockout");
919 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
920 state.rate_limiter = Arc::new(IpRateLimiter::new(
921 Duration::from_mins(1),
922 600,
923 2, Duration::from_millis(200), ));
926 build_router(state)
927}
928
929struct RuntimeSecurityConfig {
930 api_keys: Vec<secrecy::SecretBox<String>>,
931 tls_cert: Option<String>,
932 tls_key: Option<String>,
933 tls_enabled: bool,
934 trust_proxy: bool,
935 trusted_proxy_ips: Vec<IpAddr>,
936 rate_limiter: Arc<IpRateLimiter>,
937}
938
939fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
940 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
941 .or_else(|_| std::env::var("SLOC_API_KEY"))
942 .unwrap_or_default()
943 .split(',')
944 .map(str::trim)
945 .filter(|s| !s.is_empty())
946 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
947 .collect();
948 if server_mode && api_keys.is_empty() {
949 println!(
950 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
951 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
952 );
953 }
954 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
955 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
956 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
957 if server_mode && !tls_enabled {
958 println!(
959 "WARNING: TLS is not configured. Traffic is cleartext. \
960 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
961 or terminate TLS at a reverse proxy (nginx, caddy)."
962 );
963 }
964 if server_mode {
965 println!(
966 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
967 to restrict cross-origin access (comma-separated)."
968 );
969 }
970 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
971 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
972 .unwrap_or_default()
973 .split(',')
974 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
975 .collect();
976 if trust_proxy {
977 if trusted_proxy_ips.is_empty() {
978 println!(
979 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
980 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
981 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
982 );
983 } else {
984 println!(
985 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
986 trusted_proxy_ips
987 .iter()
988 .map(std::string::ToString::to_string)
989 .collect::<Vec<_>>()
990 .join(", ")
991 );
992 }
993 } else if server_mode {
994 println!(
995 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
996 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
997 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
998 enable per-client rate limiting via X-Forwarded-For."
999 );
1000 }
1001 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1002 println!(
1003 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1004 DISABLED for all git operations. Remove this variable before production use."
1005 );
1006 }
1007 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1008 .ok()
1009 .and_then(|v| v.parse::<u32>().ok())
1010 .unwrap_or(10);
1011 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1012 .ok()
1013 .and_then(|v| v.parse::<u64>().ok())
1014 .unwrap_or(3600);
1015 let default_rpm: usize = if server_mode { 120 } else { 600 };
1019 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1020 .ok()
1021 .and_then(|v| v.parse::<usize>().ok())
1022 .unwrap_or(default_rpm);
1023 let rate_limiter = Arc::new(IpRateLimiter::new(
1024 Duration::from_mins(1),
1025 rate_limit_rpm,
1026 auth_lockout_threshold,
1027 Duration::from_secs(auth_lockout_secs),
1028 ));
1029 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1030 RuntimeSecurityConfig {
1031 api_keys,
1032 tls_cert,
1033 tls_key,
1034 tls_enabled,
1035 trust_proxy,
1036 trusted_proxy_ips,
1037 rate_limiter,
1038 }
1039}
1040
1041#[allow(clippy::too_many_lines)]
1050pub async fn serve(config: AppConfig) -> Result<()> {
1051 let bind_address = config.web.bind_address.clone();
1052 let server_mode = config.web.server_mode;
1053 let output_root = resolve_output_root(None);
1054 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1056 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1057 let mut registry = ScanRegistry::load(®istry_path);
1058 registry.prune_stale();
1059 let _ = registry.save(®istry_path);
1060
1061 let sec = load_runtime_security_config(server_mode);
1062 spawn_upload_staging_cleanup();
1063
1064 let git_clones_dir = resolve_git_clones_dir(&output_root);
1065 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1066 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1067 let schedules = ScheduleStore::load(&schedules_path);
1068 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1069 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1070 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1071 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1072 |_| output_root.join("confluence_config.json"),
1073 PathBuf::from,
1074 );
1075 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1076 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1077 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1078 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1079 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1080 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1081 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1082
1083 let state = AppState {
1084 base_config: config,
1085 artifacts: Arc::new(Mutex::new(HashMap::new())),
1086 async_runs: Arc::new(Mutex::new(HashMap::new())),
1087 registry: Arc::new(Mutex::new(registry)),
1088 registry_path,
1089 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1090 server_mode,
1091 tls_enabled: sec.tls_enabled,
1092 api_keys: Arc::new(sec.api_keys),
1093 rate_limiter: sec.rate_limiter,
1094 trust_proxy: sec.trust_proxy,
1095 trusted_proxy_ips: sec.trusted_proxy_ips,
1096 git_clones_dir,
1097 schedules: Arc::new(Mutex::new(schedules)),
1098 schedules_path,
1099 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1100 scan_profiles_path,
1101 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1102 confluence: Arc::new(Mutex::new(confluence)),
1103 confluence_path,
1104 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1105 watched_dirs_path,
1106 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1107 cleanup_policy_path,
1108 cleanup_task_handle: Arc::new(Mutex::new(None)),
1109 };
1110
1111 restart_poll_schedules(&state).await;
1112 warn_insecure_gitlab_webhooks(&state).await;
1113
1114 {
1116 let enabled = state
1117 .cleanup_policy
1118 .lock()
1119 .await
1120 .policy
1121 .as_ref()
1122 .is_some_and(|p| p.enabled);
1123 if enabled {
1124 let handle = spawn_cleanup_policy_task(state.clone());
1125 *state.cleanup_task_handle.lock().await = Some(handle);
1126 }
1127 }
1128
1129 let app = build_router(state.clone());
1130
1131 let preferred: SocketAddr = bind_address
1136 .parse()
1137 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1138 let (listener, addr) = {
1139 let candidates = (0u16..=9).map(|offset| {
1140 let mut a = preferred;
1141 a.set_port(preferred.port().saturating_add(offset));
1142 a
1143 });
1144 let mut found = None;
1145 for candidate in candidates {
1146 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1147 found = Some((l, candidate));
1148 break;
1149 }
1150 }
1151 found.ok_or_else(|| {
1152 anyhow::anyhow!(
1153 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1154 bind_address,
1155 preferred.port(),
1156 preferred.port().saturating_add(9)
1157 )
1158 })?
1159 };
1160 if addr != preferred {
1161 eprintln!(
1162 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1163 using {} instead.",
1164 preferred.port(),
1165 addr.port()
1166 );
1167 }
1168
1169 if sec.tls_enabled {
1170 let cert_path = sec
1171 .tls_cert
1172 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1173 let key_path = sec
1174 .tls_key
1175 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1176 let tls_config = build_tls_config(&cert_path, &key_path)
1177 .context("failed to load TLS certificate/key")?;
1178 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1179
1180 let url = format!("https://{addr}/");
1181 println!("OxideSLOC server running at {url} (TLS)");
1182 println!("Use Ctrl+C to stop.");
1183
1184 return serve_tls(listener, app, acceptor, server_mode).await;
1185 }
1186
1187 let url = format!("http://{addr}/");
1188 log_startup_url(&url, server_mode);
1189
1190 axum::serve(
1191 listener,
1192 app.into_make_service_with_connect_info::<SocketAddr>(),
1193 )
1194 .with_graceful_shutdown(shutdown_signal(server_mode))
1195 .await
1196 .context("web server terminated unexpectedly")
1197}
1198
1199fn primary_lan_ip() -> Option<String> {
1203 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1204 socket.connect("8.8.8.8:80").ok()?;
1205 let addr = socket.local_addr().ok()?;
1206 let ip = addr.ip();
1207 if ip.is_loopback() {
1208 return None;
1209 }
1210 Some(ip.to_string())
1211}
1212
1213fn log_startup_url(url: &str, server_mode: bool) {
1215 if server_mode {
1216 println!("OxideSLOC server running at {url}");
1217 println!("Use Ctrl+C to stop.");
1218 } else {
1219 println!("OxideSLOC local web UI running at {url}");
1220 println!("Press Ctrl+C to stop the server.");
1221 let open_url = url.to_owned();
1222 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1223 }
1224}
1225
1226fn open_browser_tab(url: &str) {
1228 #[cfg(target_os = "windows")]
1233 let _ = std::process::Command::new("rundll32")
1234 .args(["url.dll,FileProtocolHandler", url])
1235 .stdout(Stdio::null())
1236 .stderr(Stdio::null())
1237 .spawn();
1238 #[cfg(target_os = "macos")]
1239 let _ = std::process::Command::new("open")
1240 .arg(url)
1241 .stdout(Stdio::null())
1242 .stderr(Stdio::null())
1243 .spawn();
1244 #[cfg(target_os = "linux")]
1245 let _ = std::process::Command::new("xdg-open")
1246 .arg(url)
1247 .stdout(Stdio::null())
1248 .stderr(Stdio::null())
1249 .spawn();
1250}
1251
1252async fn shutdown_signal(server_mode: bool) {
1254 if tokio::signal::ctrl_c().await.is_ok() {
1255 println!();
1256 if server_mode {
1257 println!("Shutting down OxideSLOC server...");
1258 } else {
1259 println!("Shutting down OxideSLOC local web UI...");
1260 }
1261 println!("Server stopped cleanly.");
1262 }
1263}
1264
1265fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1267 use rustls_pki_types::pem::PemObject;
1268 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1269
1270 let cert_bytes =
1271 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1272 let key_bytes =
1273 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1274
1275 let cert_chain: Vec<CertificateDer<'static>> =
1276 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1277 .collect::<std::result::Result<_, _>>()
1278 .context("failed to parse TLS certificates")?;
1279
1280 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1281 .context("failed to parse TLS private key")?;
1282
1283 rustls::ServerConfig::builder()
1284 .with_no_client_auth()
1285 .with_single_cert(cert_chain, key)
1286 .context("failed to build TLS server config")
1287}
1288
1289async fn serve_tls(
1291 listener: tokio::net::TcpListener,
1292 app: Router,
1293 acceptor: tokio_rustls::TlsAcceptor,
1294 server_mode: bool,
1295) -> Result<()> {
1296 use hyper_util::rt::{TokioExecutor, TokioIo};
1297 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1298 use hyper_util::service::TowerToHyperService;
1299 use tower::{Service, ServiceExt};
1300
1301 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1302
1303 loop {
1304 tokio::select! {
1305 biased;
1306 _ = tokio::signal::ctrl_c() => {
1307 println!();
1308 if server_mode {
1309 println!("Shutting down OxideSLOC server...");
1310 } else {
1311 println!("Shutting down OxideSLOC local web UI...");
1312 }
1313 println!("Server stopped cleanly.");
1314 return Ok(());
1315 }
1316 result = listener.accept() => {
1317 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1318 let acceptor = acceptor.clone();
1319 let mut factory = make_svc.clone();
1320
1321 tokio::spawn(async move {
1322 let tls = match acceptor.accept(tcp).await {
1323 Ok(s) => s,
1324 Err(e) => {
1325 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1326 return;
1327 }
1328 };
1329 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1330 Ok(f) => match Service::call(f, peer_addr).await {
1331 Ok(s) => s,
1332 Err(_) => return,
1333 },
1334 Err(_) => return,
1335 };
1336 let io = TokioIo::new(tls);
1337 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1338 .serve_connection(io, TowerToHyperService::new(svc))
1339 .await
1340 {
1341 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1342 }
1343 });
1344 }
1345 }
1346 }
1347}
1348
1349fn build_cors_layer(server_mode: bool) -> CorsLayer {
1352 if server_mode {
1353 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1354 .unwrap_or_default()
1355 .split(',')
1356 .filter(|s| !s.is_empty())
1357 .filter_map(|s| s.trim().parse().ok())
1358 .collect();
1359 if allowed.is_empty() {
1360 return CorsLayer::new();
1361 }
1362 CorsLayer::new()
1363 .allow_origin(AllowOrigin::list(allowed))
1364 .allow_methods(AllowMethods::list([
1365 axum::http::Method::GET,
1366 axum::http::Method::POST,
1367 ]))
1368 .allow_headers(AllowHeaders::list([
1369 axum::http::header::AUTHORIZATION,
1370 axum::http::header::CONTENT_TYPE,
1371 ]))
1372 } else {
1373 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1374 let s = origin.to_str().unwrap_or("");
1375 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1376 }))
1377 }
1378}
1379
1380async fn add_security_headers(
1381 State(state): State<AppState>,
1382 mut req: Request<Body>,
1383 next: Next,
1384) -> Response {
1385 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1386 req.extensions_mut().insert(CspNonce(nonce.clone()));
1387 let mut resp = next.run(req).await;
1388 inject_page_fade_into_html(&mut resp, &nonce).await;
1389 let h = resp.headers_mut();
1390 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1391 h.insert(
1392 "X-Content-Type-Options",
1393 HeaderValue::from_static("nosniff"),
1394 );
1395 h.insert(
1396 "Referrer-Policy",
1397 HeaderValue::from_static("strict-origin-when-cross-origin"),
1398 );
1399 let csp = format!(
1400 "default-src 'self'; \
1401 base-uri 'self'; \
1402 form-action 'self'; \
1403 style-src 'self' 'unsafe-inline'; \
1404 img-src 'self' data: blob:; \
1405 script-src 'self' 'nonce-{nonce}'; \
1406 font-src 'self' data:; \
1407 object-src 'none'; \
1408 frame-ancestors 'none'"
1409 );
1410 h.insert(
1411 "Content-Security-Policy",
1412 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1413 HeaderValue::from_static(
1414 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1415 )
1416 }),
1417 );
1418 h.insert(
1419 "X-Permitted-Cross-Domain-Policies",
1420 HeaderValue::from_static("none"),
1421 );
1422 h.insert(
1423 "Permissions-Policy",
1424 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1425 );
1426 h.insert(
1427 "Cross-Origin-Opener-Policy",
1428 HeaderValue::from_static("same-origin"),
1429 );
1430 h.insert(
1431 "Cross-Origin-Resource-Policy",
1432 HeaderValue::from_static("same-origin"),
1433 );
1434 h.insert(
1437 "Cross-Origin-Embedder-Policy",
1438 HeaderValue::from_static("require-corp"),
1439 );
1440 if state.tls_enabled {
1441 h.insert(
1442 "Strict-Transport-Security",
1443 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1444 );
1445 }
1446 resp
1447}
1448
1449async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1463 use axum::http::Method;
1464
1465 let is_state_changing = matches!(
1466 *req.method(),
1467 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1468 );
1469 let path = req.uri().path();
1470 let has_token_auth = req.headers().contains_key("X-API-Key")
1471 || req
1472 .headers()
1473 .get(header::AUTHORIZATION)
1474 .and_then(|v| v.to_str().ok())
1475 .is_some_and(|v| v.starts_with("Bearer "));
1476
1477 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1478 return next.run(req).await;
1479 }
1480
1481 let headers = req.headers();
1482 let header_str = |name: &header::HeaderName| {
1483 headers
1484 .get(name)
1485 .and_then(|v| v.to_str().ok())
1486 .map(str::to_owned)
1487 };
1488 let origin = header_str(&header::ORIGIN);
1489 let referer = header_str(&header::REFERER);
1490 let host = header_str(&header::HOST);
1491
1492 let authority_of = |url: &str| -> Option<String> {
1494 url.split_once("://")
1495 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1496 };
1497
1498 let source_authority = origin
1499 .as_deref()
1500 .and_then(authority_of)
1501 .or_else(|| referer.as_deref().and_then(authority_of));
1502
1503 match (source_authority, host) {
1504 (None, _) => next.run(req).await,
1506 (Some(src), Some(h)) if src == h => next.run(req).await,
1507 (Some(src), host) => {
1508 tracing::warn!(
1509 event = "csrf_rejected",
1510 path = %path,
1511 origin = %src,
1512 host = ?host,
1513 "Cross-origin state-changing request rejected (CSRF guard)"
1514 );
1515 (
1516 StatusCode::FORBIDDEN,
1517 "403 Forbidden — cross-origin request rejected\n",
1518 )
1519 .into_response()
1520 }
1521 }
1522}
1523
1524fn page_fade_html(nonce: &str) -> String {
1532 const STYLE: &str = r"<style>
1541@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1542.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1543body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1544@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1545</style>";
1546 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');});})();";
1555 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1556}
1557
1558fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1573 const TPL: &str = r#"<style nonce="__N__">
1574html.sloc-pending body{visibility:hidden;}
1575html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1576#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%);}
1577#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1578body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1579body.pdf-mode #rpt-loading-overlay{display:none!important;}
1580.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1581.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;}
1582.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;}
1583@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1584@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1585body.dark-theme .rpt-bg-blob{opacity:.36;}
1586.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;}
1587@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1588body.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);}
1589.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1590.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1591.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1592.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));}
1593@keyframes rpt-spin{to{transform:rotate(360deg);}}
1594.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;}
1595body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1596body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1597.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1598.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;}
1599@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1600.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1601.rpt-dot:nth-child(2){animation-delay:.28s;}
1602.rpt-dot:nth-child(3){animation-delay:.56s;}
1603@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1604.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1605.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1606.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;}
1607body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1608@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;}}
1609</style>
1610<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1611<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>
1612<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1613 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1614 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1615 <div class="rpt-load-card">
1616 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1617 <div class="rpt-spinner-wrap">
1618 <div class="rpt-spinner-track"></div>
1619 <div class="rpt-spinner"></div>
1620 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1621 </div>
1622 <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>
1623 <div class="rpt-status" id="rpt-status">__LABEL__</div>
1624 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1625 </div>
1626</div>
1627<script nonce="__N__">
1628(function(){
1629 var ov=document.getElementById('rpt-loading-overlay');
1630 var root=document.documentElement;
1631 function reveal(){root.classList.remove('sloc-pending');}
1632 if(!ov){reveal();return;}
1633 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1634 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1635 var mi=0,prog=0,done=false,start=Date.now();
1636 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1637 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1638 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1639 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1640 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1641 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1642 setProg(8);
1643 var msgTimer=setInterval(nextMsg,700);
1644 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);
1645 // These pages draw charts into known SVG containers that start empty and are
1646 // filled by JS once layout is available (some only after a ResizeObserver pass
1647 // post-`load`). Treat the page as ready only once every chart container present
1648 // actually has rendered content, so the overlay never lifts on a half-drawn page.
1649 function chartsRendered(){
1650 var sel=['#cmp-tl-svg','#mc-chart'];
1651 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1652 return true;
1653 }
1654 function finish(){
1655 if(done)return;done=true;
1656 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1657 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1658 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1659 reveal();
1660 requestAnimationFrame(function(){requestAnimationFrame(function(){
1661 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1662 });});
1663 }
1664 // Wait for `load` (resources + first layout), then poll until the charts have
1665 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1666 function afterLoad(){
1667 var loadAt=Date.now();
1668 (function poll(){
1669 if(done)return;
1670 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1671 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1672 return;
1673 }
1674 requestAnimationFrame(poll);
1675 })();
1676 }
1677 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1678 // Absolute safety net: never let the gate/overlay get stuck.
1679 setTimeout(function(){if(!done)finish();},HARD_CAP);
1680})();
1681</script>"#;
1682 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1683}
1684
1685fn sloc_toast_assets(nonce: &str) -> String {
1700 const TPL: &str = r##"<style nonce="__N__">
1701#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;}
1702.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);}
1703.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1704.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1705.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;}
1706.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1707.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1708.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1709.sloc-toast-success{border-color:#bfe0cc;}
1710.sloc-toast-error{border-color:#e6b3b3;}
1711.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1712.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;}
1713@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1714.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;}
1715.sloc-toast-x:hover{opacity:1;}
1716body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1717body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1718body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1719body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1720@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1721</style>
1722<script nonce="__N__">
1723(function(){
1724 if(window.slocToast)return;
1725 function wrap(){
1726 var w=document.getElementById('sloc-toast-wrap');
1727 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);}
1728 return w;
1729 }
1730 window.slocToast=function(msg,opts){
1731 opts=opts||{};
1732 var type=opts.type||'info';
1733 var loading=type==='loading';
1734 var t=document.createElement('div');
1735 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1736 t.setAttribute('role',type==='error'?'alert':'status');
1737 var ico=loading
1738 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1739 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1740 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1741 t.querySelector('.sloc-toast-msg').textContent=String(msg);
1742 wrap().appendChild(t);
1743 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1744 var gone=false,timer=null;
1745 function close(){
1746 if(gone)return;gone=true;if(timer)clearTimeout(timer);
1747 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1748 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1749 }
1750 t.querySelector('.sloc-toast-x').addEventListener('click',close);
1751 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1752 if(ttl>0)timer=setTimeout(close,ttl);
1753 return {dismiss:close,el:t};
1754 };
1755 window.slocExportPdf=function(o){
1756 o=o||{};
1757 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1758 if(btn&&btn.disabled)return;
1759 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1760 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1761 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1762 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1763 .then(function(blob){
1764 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1765 document.body.appendChild(a);a.click();document.body.removeChild(a);
1766 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1767 load.dismiss();
1768 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1769 })
1770 .catch(function(e){
1771 load.dismiss();
1772 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1773 })
1774 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1775 };
1776})();
1777</script>"##;
1778 TPL.replace("__N__", nonce)
1779}
1780
1781async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1786 let is_html = resp
1787 .headers()
1788 .get(header::CONTENT_TYPE)
1789 .and_then(|v| v.to_str().ok())
1790 .is_some_and(|v| v.starts_with("text/html"));
1791 if !is_html {
1792 return;
1793 }
1794 let body = std::mem::replace(resp.body_mut(), Body::empty());
1795 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1796 return;
1797 };
1798 let html = match String::from_utf8(bytes.to_vec()) {
1799 Ok(s) => s,
1800 Err(e) => {
1801 *resp.body_mut() = Body::from(e.into_bytes());
1802 return;
1803 }
1804 };
1805 if html.contains("id=\"rpt-loading-overlay\"") {
1806 *resp.body_mut() = Body::from(html);
1807 return;
1808 }
1809 let insert_at = html
1813 .find("<body")
1814 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1815 .or_else(|| {
1816 let lower = html.to_ascii_lowercase();
1817 lower
1818 .find("<body")
1819 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1820 });
1821 let new_html = match insert_at {
1822 Some(at) => {
1823 let mut out = String::with_capacity(html.len() + 1024);
1824 out.push_str(&html[..at]);
1825 out.push_str(&page_fade_html(nonce));
1826 out.push_str(&html[at..]);
1827 out
1828 }
1829 None => html,
1830 };
1831 resp.headers_mut().remove(header::CONTENT_LENGTH);
1832 *resp.body_mut() = Body::from(new_html);
1833}
1834
1835async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1836 let peer_ip = req
1837 .extensions()
1838 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1839 .map(|c| c.0.ip());
1840
1841 let ip = peer_ip
1845 .and_then(|peer| {
1846 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1847 req.headers()
1848 .get("X-Forwarded-For")
1849 .and_then(|v| v.to_str().ok())
1850 .and_then(|s| s.split(',').next())
1851 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1852 } else {
1853 None
1854 }
1855 })
1856 .or(peer_ip)
1857 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1858
1859 if !state.rate_limiter.is_allowed(ip) {
1860 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1861 path = %req.uri().path(), "Rate limit exceeded");
1862 return (
1863 StatusCode::TOO_MANY_REQUESTS,
1864 [(header::RETRY_AFTER, "60")],
1865 "429 Too Many Requests\n",
1866 )
1867 .into_response();
1868 }
1869 next.run(req).await
1870}
1871
1872async fn splash(
1873 State(state): State<AppState>,
1874 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1875) -> impl IntoResponse {
1876 let lan_ip = if state.server_mode {
1877 primary_lan_ip()
1878 } else {
1879 None
1880 };
1881 let port = state
1882 .base_config
1883 .web
1884 .bind_address
1885 .rsplit(':')
1886 .next()
1887 .and_then(|p| p.parse::<u16>().ok())
1888 .unwrap_or(4317);
1889 let has_api_key = !state.api_keys.is_empty();
1890 let template = SplashTemplate {
1891 csp_nonce,
1892 server_mode: state.server_mode,
1893 lan_ip,
1894 port,
1895 version: env!("CARGO_PKG_VERSION"),
1896 has_api_key,
1897 };
1898 Html(
1899 template
1900 .render()
1901 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1902 )
1903}
1904
1905async fn index(
1906 State(state): State<AppState>,
1907 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1908 Query(query): Query<IndexQuery>,
1909) -> impl IntoResponse {
1910 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1911 let policy = query
1912 .mixed_line_policy
1913 .unwrap_or_else(|| "code_only".to_string());
1914 let behavior = query
1915 .binary_file_behavior
1916 .unwrap_or_else(|| "skip".to_string());
1917 let cfg = ScanConfig {
1918 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1919 path: query.path.unwrap_or_default(),
1920 include_globs: query.include_globs.unwrap_or_default(),
1921 exclude_globs: query.exclude_globs.unwrap_or_default(),
1922 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1923 mixed_line_policy: policy,
1924 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1925 != Some("off"),
1926 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1927 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1928 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1929 != Some("disabled"),
1930 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1931 binary_file_behavior: behavior,
1932 output_dir: query.output_dir.unwrap_or_default(),
1933 report_title: query.report_title.unwrap_or_default(),
1934 continuation_line_policy: query
1935 .continuation_line_policy
1936 .unwrap_or_else(default_each_physical_line),
1937 blank_in_block_comment_policy: query
1938 .blank_in_block_comment_policy
1939 .unwrap_or_else(default_count_as_comment),
1940 count_compiler_directives: query.count_compiler_directives.as_deref()
1941 != Some("disabled"),
1942 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
1943 style_col_threshold: query
1944 .style_col_threshold
1945 .as_deref()
1946 .and_then(|s| s.parse().ok())
1947 .unwrap_or(80),
1948 style_score_threshold: query
1949 .style_score_threshold
1950 .as_deref()
1951 .and_then(|s| s.parse().ok())
1952 .unwrap_or(0),
1953 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
1954 coverage_file: query.coverage_file.unwrap_or_default(),
1955 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
1956 complexity_alert: query
1957 .complexity_alert
1958 .as_deref()
1959 .and_then(|s| s.parse().ok())
1960 .unwrap_or(0),
1961 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
1962 activity_window: query
1963 .activity_window
1964 .as_deref()
1965 .and_then(|s| s.parse().ok())
1966 .unwrap_or(90),
1967 };
1968 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1969 } else {
1970 "{}".to_string()
1971 };
1972
1973 let git_repo = query.git_repo.unwrap_or_default();
1974 let git_ref = query.git_ref.unwrap_or_default();
1975
1976 let git_label = make_git_label(&git_repo, &git_ref);
1977 let git_output_dir = if git_label.is_empty() {
1978 String::new()
1979 } else {
1980 desktop_dir().join(&git_label).display().to_string()
1981 };
1982 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1983 let git_output_dir_json =
1984 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1985
1986 let template = IndexTemplate {
1987 version: env!("CARGO_PKG_VERSION"),
1988 prefill_json,
1989 csp_nonce,
1990 git_repo,
1991 git_ref,
1992 git_label_json,
1993 git_output_dir_json,
1994 server_mode: state.server_mode,
1995 };
1996
1997 Html(
1998 template
1999 .render()
2000 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2001 )
2002}
2003
2004async fn scan_setup_handler(
2005 State(state): State<AppState>,
2006 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2007) -> impl IntoResponse {
2008 let recent_scans_json = {
2009 let arr: Vec<serde_json::Value> = {
2010 let reg = state.registry.lock().await;
2011 reg.entries
2012 .iter()
2013 .rev()
2014 .take(6)
2015 .map(|e| {
2016 let run_dir = e
2017 .html_path
2018 .as_ref()
2019 .or(e.json_path.as_ref())
2020 .and_then(|p| p.parent().map(PathBuf::from));
2021 let config_val: Option<serde_json::Value> = run_dir
2022 .and_then(|d| find_scan_config_in_dir(&d))
2023 .and_then(|p| fs::read_to_string(&p).ok())
2024 .and_then(|s| serde_json::from_str(&s).ok());
2025 serde_json::json!({
2026 "project_label": e.project_label,
2027 "timestamp": fmt_la_time(e.timestamp_utc),
2028 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2029 "config": config_val,
2030 })
2031 })
2032 .collect()
2033 };
2034 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2035 };
2036
2037 let template = ScanSetupTemplate {
2038 version: env!("CARGO_PKG_VERSION"),
2039 recent_scans_json,
2040 csp_nonce,
2041 };
2042 Html(
2043 template
2044 .render()
2045 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2046 )
2047}
2048
2049async fn healthz() -> &'static str {
2050 "ok"
2051}
2052
2053async fn api_version_handler() -> impl IntoResponse {
2054 axum::Json(serde_json::json!({
2055 "name": "oxide-sloc",
2056 "version": env!("CARGO_PKG_VERSION"),
2057 }))
2058}
2059
2060fn prom_runs_total() -> &'static prometheus::IntCounter {
2063 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2064 COUNTER.get_or_init(|| {
2065 prometheus::register_int_counter!(
2066 "oxide_sloc_runs_total",
2067 "Total number of completed analysis runs"
2068 )
2069 .expect("failed to register oxide_sloc_runs_total counter")
2070 })
2071}
2072
2073async fn metrics_handler() -> impl IntoResponse {
2074 use prometheus::Encoder as _;
2075 let mut buf = Vec::new();
2076 let encoder = prometheus::TextEncoder::new();
2077 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2078 (
2079 [(
2080 axum::http::header::CONTENT_TYPE,
2081 "text/plain; version=0.0.4; charset=utf-8",
2082 )],
2083 buf,
2084 )
2085}
2086
2087static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2088
2089async fn openapi_yaml_handler() -> impl IntoResponse {
2090 (
2091 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2092 OPENAPI_YAML,
2093 )
2094}
2095
2096static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2097static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2098
2099async fn llms_txt_handler() -> impl IntoResponse {
2100 (
2101 [
2102 (
2103 axum::http::header::CONTENT_TYPE,
2104 "text/plain; charset=utf-8",
2105 ),
2106 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2107 ],
2108 LLMS_TXT,
2109 )
2110}
2111
2112async fn llms_full_txt_handler() -> impl IntoResponse {
2113 (
2114 [
2115 (
2116 axum::http::header::CONTENT_TYPE,
2117 "text/plain; charset=utf-8",
2118 ),
2119 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2120 ],
2121 LLMS_FULL_TXT,
2122 )
2123}
2124
2125async fn api_docs_handler(
2126 State(state): State<AppState>,
2127 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2128) -> impl IntoResponse {
2129 let has_api_key = !state.api_keys.is_empty();
2130 Html(
2131 ApiDocsTemplate {
2132 has_api_key,
2133 csp_nonce,
2134 version: env!("CARGO_PKG_VERSION"),
2135 }
2136 .render()
2137 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2138 )
2139}
2140
2141async fn chart_js_handler() -> impl IntoResponse {
2142 (
2143 [
2144 (
2145 header::CONTENT_TYPE,
2146 "application/javascript; charset=utf-8",
2147 ),
2148 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2149 ],
2150 CHART_JS,
2151 )
2152}
2153
2154async fn report_chart_js_handler() -> impl IntoResponse {
2155 (
2156 [
2157 (
2158 header::CONTENT_TYPE,
2159 "application/javascript; charset=utf-8",
2160 ),
2161 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2162 ],
2163 REPORT_CHART_JS,
2164 )
2165}
2166
2167#[derive(Debug, Deserialize)]
2168struct AnalyzeForm {
2169 path: String,
2170 git_repo: Option<String>,
2171 git_ref: Option<String>,
2172 mixed_line_policy: Option<MixedLinePolicy>,
2173 python_docstrings_as_comments: Option<String>,
2174 generated_file_detection: Option<String>,
2175 minified_file_detection: Option<String>,
2176 vendor_directory_detection: Option<String>,
2177 include_lockfiles: Option<String>,
2178 binary_file_behavior: Option<BinaryFileBehavior>,
2179 output_dir: Option<String>,
2180 report_title: Option<String>,
2181 report_header_footer: Option<String>,
2182 include_globs: Option<String>,
2183 exclude_globs: Option<String>,
2184 submodule_breakdown: Option<String>,
2185 coverage_file: Option<String>,
2186 continuation_line_policy: Option<ContinuationLinePolicy>,
2187 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2188 count_compiler_directives: Option<String>,
2189 style_col_threshold: Option<String>,
2190 style_analysis_enabled: Option<String>,
2191 style_score_threshold: Option<String>,
2192 style_lang_scope: Option<String>,
2193 cocomo_mode: Option<String>,
2195 complexity_alert: Option<String>,
2197 exclude_duplicates: Option<String>,
2199 activity_window: Option<String>,
2201}
2202
2203#[allow(clippy::struct_excessive_bools)]
2204#[derive(Debug, Serialize, Deserialize, Clone)]
2205struct ScanConfig {
2206 oxide_sloc_version: String,
2207 path: String,
2208 include_globs: String,
2209 exclude_globs: String,
2210 submodule_breakdown: bool,
2211 mixed_line_policy: String,
2212 python_docstrings_as_comments: bool,
2213 generated_file_detection: bool,
2214 minified_file_detection: bool,
2215 vendor_directory_detection: bool,
2216 include_lockfiles: bool,
2217 binary_file_behavior: String,
2218 output_dir: String,
2219 report_title: String,
2220 #[serde(default = "default_each_physical_line")]
2222 continuation_line_policy: String,
2223 #[serde(default = "default_count_as_comment")]
2224 blank_in_block_comment_policy: String,
2225 #[serde(default = "default_true_bool")]
2226 count_compiler_directives: bool,
2227 #[serde(default = "default_true_bool")]
2228 style_analysis_enabled: bool,
2229 #[serde(default = "default_style_col_threshold")]
2230 style_col_threshold: u16,
2231 #[serde(default)]
2232 style_score_threshold: u8,
2233 #[serde(default = "default_all_scope")]
2234 style_lang_scope: String,
2235 #[serde(default)]
2236 coverage_file: String,
2237 #[serde(default = "default_organic")]
2238 cocomo_mode: String,
2239 #[serde(default)]
2240 complexity_alert: u32,
2241 #[serde(default)]
2242 exclude_duplicates: bool,
2243 #[serde(default = "default_activity_window")]
2245 activity_window: u32,
2246}
2247
2248const fn default_activity_window() -> u32 {
2249 90
2250}
2251
2252fn default_each_physical_line() -> String {
2253 "each_physical_line".to_string()
2254}
2255fn default_count_as_comment() -> String {
2256 "count_as_comment".to_string()
2257}
2258const fn default_true_bool() -> bool {
2259 true
2260}
2261const fn default_style_col_threshold() -> u16 {
2262 80
2263}
2264fn default_all_scope() -> String {
2265 "all".to_string()
2266}
2267fn default_organic() -> String {
2268 "organic".to_string()
2269}
2270
2271#[derive(Debug, Deserialize, Default)]
2272struct IndexQuery {
2273 path: Option<String>,
2274 include_globs: Option<String>,
2275 exclude_globs: Option<String>,
2276 submodule_breakdown: Option<String>,
2277 mixed_line_policy: Option<String>,
2278 python_docstrings_as_comments: Option<String>,
2279 generated_file_detection: Option<String>,
2280 minified_file_detection: Option<String>,
2281 vendor_directory_detection: Option<String>,
2282 include_lockfiles: Option<String>,
2283 binary_file_behavior: Option<String>,
2284 output_dir: Option<String>,
2285 report_title: Option<String>,
2286 prefilled: Option<String>,
2287 git_repo: Option<String>,
2288 git_ref: Option<String>,
2289 continuation_line_policy: Option<String>,
2291 blank_in_block_comment_policy: Option<String>,
2292 count_compiler_directives: Option<String>,
2293 style_analysis_enabled: Option<String>,
2294 style_col_threshold: Option<String>,
2295 style_score_threshold: Option<String>,
2296 style_lang_scope: Option<String>,
2297 coverage_file: Option<String>,
2298 cocomo_mode: Option<String>,
2299 complexity_alert: Option<String>,
2300 exclude_duplicates: Option<String>,
2301 activity_window: Option<String>,
2302}
2303
2304#[derive(Debug, Deserialize)]
2305struct PreviewQuery {
2306 path: Option<String>,
2307 include_globs: Option<String>,
2308 exclude_globs: Option<String>,
2309}
2310
2311#[cfg(feature = "native-dialog")]
2312#[derive(Debug, Deserialize)]
2313struct PickDirectoryQuery {
2314 kind: Option<String>,
2315 current: Option<String>,
2316}
2317
2318#[cfg(not(feature = "native-dialog"))]
2319#[derive(Debug, Deserialize)]
2320struct PickDirectoryQuery {}
2321
2322#[derive(Debug, Deserialize, Default)]
2323struct ArtifactQuery {
2324 download: Option<String>,
2325}
2326
2327#[cfg(feature = "native-dialog")]
2328#[derive(Debug, Serialize)]
2329struct PickDirectoryResponse {
2330 selected_path: Option<String>,
2331 cancelled: bool,
2332}
2333
2334#[cfg(feature = "native-dialog")]
2335async fn pick_directory_handler(
2336 State(state): State<AppState>,
2337 Query(query): Query<PickDirectoryQuery>,
2338) -> Response {
2339 if state.server_mode {
2340 return StatusCode::NOT_FOUND.into_response();
2341 }
2342 if std::env::var("SLOC_HEADLESS").is_ok() {
2344 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2345 .into_response();
2346 }
2347
2348 let is_coverage = query.kind.as_deref() == Some("coverage");
2349 let title = match query.kind.as_deref() {
2350 Some("output") => "Select output directory",
2351 Some("reports") => "Select folder containing saved reports",
2352 Some("coverage") => "Select LCOV coverage file",
2353 _ => "Select project directory",
2354 }
2355 .to_owned();
2356 let current = query.current.clone();
2357
2358 let picked = tokio::task::spawn_blocking(move || {
2359 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2362 let fg_tid = win_dialog_focus::attach_to_foreground();
2363 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2364 win_dialog_focus::flash_dialog_when_ready(title.clone());
2365
2366 let mut dialog = rfd::FileDialog::new().set_title(&title);
2367 if let Some(current) = current.as_deref() {
2368 let resolved = resolve_input_path(current);
2369 let seed = if resolved.is_dir() {
2370 Some(resolved)
2371 } else {
2372 resolved.parent().map(Path::to_path_buf)
2373 };
2374 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2375 dialog = dialog.set_directory(seed_dir);
2376 }
2377 }
2378 let result = if is_coverage {
2379 dialog
2380 .add_filter(
2381 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2382 &["info", "lcov", "xml", "json"],
2383 )
2384 .pick_file()
2385 } else {
2386 dialog.pick_folder()
2387 };
2388
2389 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2390 win_dialog_focus::detach_from_foreground(fg_tid);
2391
2392 result
2393 })
2394 .await
2395 .unwrap_or(None);
2396
2397 Json(PickDirectoryResponse {
2398 selected_path: picked.as_ref().map(|p| display_path(p)),
2399 cancelled: picked.is_none(),
2400 })
2401 .into_response()
2402}
2403
2404#[cfg(not(feature = "native-dialog"))]
2405async fn pick_directory_handler(
2406 State(_state): State<AppState>,
2407 Query(_query): Query<PickDirectoryQuery>,
2408) -> Response {
2409 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2410}
2411
2412#[cfg(feature = "native-dialog")]
2413async fn pick_file_handler(State(state): State<AppState>) -> Response {
2414 if state.server_mode {
2415 return StatusCode::NOT_FOUND.into_response();
2416 }
2417 if std::env::var("SLOC_HEADLESS").is_ok() {
2418 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2419 .into_response();
2420 }
2421 let picked = tokio::task::spawn_blocking(|| {
2422 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2423 let fg_tid = win_dialog_focus::attach_to_foreground();
2424 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2425 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2426
2427 let result = rfd::FileDialog::new()
2428 .set_title("Select HTML report")
2429 .add_filter("HTML report", &["html"])
2430 .pick_file();
2431
2432 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2433 win_dialog_focus::detach_from_foreground(fg_tid);
2434
2435 result
2436 })
2437 .await
2438 .unwrap_or(None);
2439 Json(PickDirectoryResponse {
2440 selected_path: picked.as_ref().map(|p| display_path(p)),
2441 cancelled: picked.is_none(),
2442 })
2443 .into_response()
2444}
2445
2446#[cfg(not(feature = "native-dialog"))]
2447async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2448 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2449}
2450
2451fn is_upload_tmp_path(path: &Path) -> bool {
2456 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2457 path.starts_with(&upload_root)
2458}
2459
2460fn is_sample_path(path: &Path) -> bool {
2463 let root = workspace_root();
2464 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2465}
2466
2467fn upload_base_dir() -> PathBuf {
2469 std::env::temp_dir().join("oxide-sloc-uploads")
2470}
2471
2472fn upload_staging_path(id: &str) -> PathBuf {
2474 upload_base_dir().join(id)
2475}
2476
2477#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2481 const MAX_FILES: usize = 50_000;
2482 if body.files.is_empty() {
2483 return Err((
2484 StatusCode::BAD_REQUEST,
2485 Json(serde_json::json!({"error": "No files received"})),
2486 )
2487 .into_response());
2488 }
2489 if body.files.len() > MAX_FILES {
2490 return Err((
2491 StatusCode::PAYLOAD_TOO_LARGE,
2492 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2493 )
2494 .into_response());
2495 }
2496 Ok(())
2497}
2498
2499fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2502 match id {
2503 Some(id)
2504 if !id.is_empty()
2505 && id.len() <= 36
2506 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2507 {
2508 (id.to_string(), upload_staging_path(id))
2509 }
2510 _ => {
2511 let new_id = uuid::Uuid::new_v4().to_string();
2512 let staging = upload_staging_path(&new_id);
2513 (new_id, staging)
2514 }
2515 }
2516}
2517
2518#[allow(clippy::result_large_err)]
2523async fn stage_decoded_entry(
2524 entry: &UploadedFile,
2525 staging: &Path,
2526 total_bytes: &mut usize,
2527 project_root: &mut Option<PathBuf>,
2528) -> Result<(), Response> {
2529 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2530
2531 let Ok(data) = base64::Engine::decode(
2532 &base64::engine::general_purpose::STANDARD,
2533 entry.content.as_bytes(),
2534 ) else {
2535 return Ok(());
2536 };
2537
2538 *total_bytes += data.len();
2539 if *total_bytes > MAX_TOTAL_BYTES {
2540 return Err((
2541 StatusCode::PAYLOAD_TOO_LARGE,
2542 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2543 )
2544 .into_response());
2545 }
2546
2547 let rel = std::path::Path::new(&entry.path);
2548 if project_root.is_none() {
2549 if let Some(first) = rel.components().next() {
2550 *project_root = Some(staging.join(first.as_os_str()));
2551 }
2552 }
2553
2554 let dest = staging.join(rel);
2555 if let Some(parent) = dest.parent() {
2556 if tokio::fs::create_dir_all(parent).await.is_err() {
2557 return Err((
2558 StatusCode::INTERNAL_SERVER_ERROR,
2559 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2560 )
2561 .into_response());
2562 }
2563 }
2564
2565 if tokio::fs::write(&dest, &data).await.is_err() {
2566 return Err((
2567 StatusCode::INTERNAL_SERVER_ERROR,
2568 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2569 )
2570 .into_response());
2571 }
2572
2573 Ok(())
2574}
2575
2576async fn write_upload_files(
2580 files: &[UploadedFile],
2581 staging: &Path,
2582 upload_id: &str,
2583) -> Result<(usize, Option<PathBuf>), Response> {
2584 let mut total_bytes: usize = 0;
2585 let mut project_root: Option<PathBuf> = None;
2586
2587 for entry in files {
2588 let rel = std::path::Path::new(&entry.path);
2589 if rel
2590 .components()
2591 .any(|c| matches!(c, std::path::Component::ParentDir))
2592 {
2593 let _ = tokio::fs::remove_dir_all(staging).await;
2595 tracing::warn!(
2596 event = "upload_path_traversal",
2597 upload_id = %upload_id,
2598 path = %entry.path,
2599 "Upload rejected: path traversal component detected"
2600 );
2601 return Err((
2602 StatusCode::BAD_REQUEST,
2603 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2604 )
2605 .into_response());
2606 }
2607
2608 if let Err(resp) =
2609 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2610 {
2611 let _ = tokio::fs::remove_dir_all(staging).await;
2612 return Err(resp);
2613 }
2614 }
2615
2616 Ok((files.len(), project_root))
2617}
2618
2619fn parse_tarball_size_caps() -> (u64, u64) {
2622 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2623 .ok()
2624 .and_then(|v| v.parse().ok())
2625 .unwrap_or(2048_u64)
2626 * 1024
2627 * 1024;
2628 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2629 .ok()
2630 .and_then(|v| v.parse().ok())
2631 .unwrap_or(10_240_u64)
2632 * 1024
2633 * 1024;
2634 (compressed, decompressed)
2635}
2636
2637fn tarball_http_body_limit_bytes() -> usize {
2641 std::env::var("SLOC_MAX_TARBALL_MB")
2642 .ok()
2643 .and_then(|v| v.parse::<usize>().ok())
2644 .unwrap_or(2048)
2645 .saturating_mul(1024 * 1024)
2646}
2647
2648#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2653 body: axum::body::Body,
2654 dest_path: &Path,
2655 max_bytes: u64,
2656) -> Result<u64, Response> {
2657 use http_body_util::BodyExt as _;
2658 use tokio::io::AsyncWriteExt as _;
2659
2660 let mut file = match tokio::fs::File::create(dest_path).await {
2661 Ok(f) => f,
2662 Err(e) => {
2663 tracing::error!(
2664 event = "upload_io_error",
2665 "failed to create tarball temp file: {e}"
2666 );
2667 return Err((
2668 StatusCode::INTERNAL_SERVER_ERROR,
2669 Json(serde_json::json!({"error": "Upload initialization failed"})),
2670 )
2671 .into_response());
2672 }
2673 };
2674
2675 let mut body = body;
2676 let mut written: u64 = 0;
2677 loop {
2678 match body.frame().await {
2679 None => break,
2680 Some(Err(e)) => {
2681 let _ = tokio::fs::remove_file(dest_path).await;
2682 return Err((
2683 StatusCode::BAD_REQUEST,
2684 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2685 )
2686 .into_response());
2687 }
2688 Some(Ok(frame)) => {
2689 if let Ok(data) = frame.into_data() {
2690 written += data.len() as u64;
2691 if written > max_bytes {
2692 let _ = tokio::fs::remove_file(dest_path).await;
2693 return Err((
2694 StatusCode::PAYLOAD_TOO_LARGE,
2695 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2696 )
2697 .into_response());
2698 }
2699 if let Err(e) = file.write_all(&data).await {
2700 let _ = tokio::fs::remove_file(dest_path).await;
2701 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2702 return Err((
2703 StatusCode::INTERNAL_SERVER_ERROR,
2704 Json(serde_json::json!({"error": "Upload write failed"})),
2705 )
2706 .into_response());
2707 }
2708 }
2709 }
2710 }
2711 }
2712 drop(file);
2713 Ok(written)
2714}
2715
2716#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2721 tarball_path: &Path,
2722 staging: &Path,
2723 max_decompressed_bytes: u64,
2724) -> Result<(), Response> {
2725 let staging_clone = staging.to_path_buf();
2726 let tarball_clone = tarball_path.to_path_buf();
2727 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2728 let file = std::fs::File::open(&tarball_clone)?;
2729 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2730 let limited = SizeLimitReader {
2731 inner: gz,
2732 remaining: max_decompressed_bytes,
2733 };
2734 let mut archive = tar::Archive::new(limited);
2735 archive.set_overwrite(true);
2736 archive.set_preserve_permissions(false);
2737 std::fs::create_dir_all(&staging_clone)?;
2738 archive.unpack(&staging_clone)?;
2739 Ok(())
2740 })
2741 .await;
2742 let _ = tokio::fs::remove_file(tarball_path).await;
2743
2744 match extract_result {
2745 Ok(Ok(())) => Ok(()),
2746 Ok(Err(e)) => {
2747 let _ = tokio::fs::remove_dir_all(staging).await;
2748 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2749 tracing::warn!(
2750 event = "upload_extract_error",
2751 "tarball extraction failed: {e:#}"
2752 );
2753 let (status, msg) = if is_size_limit {
2754 (
2755 StatusCode::PAYLOAD_TOO_LARGE,
2756 "Archive exceeds the decompressed size limit",
2757 )
2758 } else {
2759 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2760 };
2761 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2762 }
2763 Err(e) => {
2764 let _ = tokio::fs::remove_dir_all(staging).await;
2765 tracing::error!(
2766 event = "upload_extract_panic",
2767 "tarball extraction task panicked: {e}"
2768 );
2769 Err((
2770 StatusCode::INTERNAL_SERVER_ERROR,
2771 Json(serde_json::json!({"error": "Archive extraction failed"})),
2772 )
2773 .into_response())
2774 }
2775 }
2776}
2777
2778async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2782 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2783 let first = entries.next_entry().await.ok()??;
2784 if !first.path().is_dir() {
2785 return None;
2786 }
2787 if entries.next_entry().await.unwrap_or(None).is_some() {
2788 return None;
2789 }
2790 Some(first.path())
2791}
2792
2793#[derive(Deserialize)]
2800struct UploadDirRequest {
2801 files: Vec<UploadedFile>,
2802 upload_id: Option<String>,
2805}
2806
2807#[derive(Deserialize)]
2808struct UploadedFile {
2809 path: String,
2811 content: String,
2813}
2814
2815async fn upload_directory_handler(
2825 State(state): State<AppState>,
2826 Json(body): Json<UploadDirRequest>,
2827) -> Response {
2828 if !state.server_mode {
2829 return StatusCode::NOT_FOUND.into_response();
2830 }
2831 if let Err(resp) = validate_upload_dir_request(&body) {
2832 return resp;
2833 }
2834 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2837 match write_upload_files(&body.files, &staging, &upload_id).await {
2838 Ok((file_count, project_root)) => {
2839 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2840 Json(serde_json::json!({
2841 "tmp_path": scan_root.to_string_lossy(),
2842 "file_count": file_count,
2843 "upload_id": upload_id.clone()
2844 }))
2845 .into_response()
2846 }
2847 Err(resp) => resp,
2848 }
2849}
2850
2851#[derive(Deserialize)]
2853struct UploadFileRequest {
2854 filename: String,
2856 content: String,
2858}
2859
2860async fn upload_file_handler(
2866 State(state): State<AppState>,
2867 Json(body): Json<UploadFileRequest>,
2868) -> Response {
2869 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2872 return StatusCode::NOT_FOUND.into_response();
2873 }
2874
2875 let Ok(data) = base64::Engine::decode(
2876 &base64::engine::general_purpose::STANDARD,
2877 body.content.as_bytes(),
2878 ) else {
2879 return (
2880 StatusCode::BAD_REQUEST,
2881 Json(serde_json::json!({"error": "Invalid base64 content"})),
2882 )
2883 .into_response();
2884 };
2885
2886 if data.len() > MAX_FILE_BYTES {
2887 return (
2888 StatusCode::PAYLOAD_TOO_LARGE,
2889 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2890 )
2891 .into_response();
2892 }
2893
2894 let filename = std::path::Path::new(&body.filename)
2896 .file_name()
2897 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2898
2899 let upload_id = uuid::Uuid::new_v4();
2900 let staging = std::env::temp_dir()
2901 .join("oxide-sloc-uploads")
2902 .join(upload_id.to_string());
2903
2904 if tokio::fs::create_dir_all(&staging).await.is_err() {
2905 return (
2906 StatusCode::INTERNAL_SERVER_ERROR,
2907 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2908 )
2909 .into_response();
2910 }
2911
2912 let dest = staging.join(&filename);
2913 if tokio::fs::write(&dest, &data).await.is_err() {
2914 let _ = tokio::fs::remove_dir_all(&staging).await;
2915 return (
2916 StatusCode::INTERNAL_SERVER_ERROR,
2917 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2918 )
2919 .into_response();
2920 }
2921
2922 Json(serde_json::json!({
2923 "tmp_path": dest.to_string_lossy(),
2924 "upload_id": upload_id.to_string()
2925 }))
2926 .into_response()
2927}
2928
2929struct SizeLimitReader<R> {
2945 inner: R,
2946 remaining: u64,
2947}
2948impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2949 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2950 if self.remaining == 0 {
2951 return Err(std::io::Error::other("decompressed size limit exceeded"));
2952 }
2953 let n = self.inner.read(buf)?;
2954 self.remaining = self.remaining.saturating_sub(n as u64);
2955 Ok(n)
2956 }
2957}
2958
2959async fn upload_tarball_handler(
2960 State(state): State<AppState>,
2961 request: axum::extract::Request,
2962) -> Response {
2963 if !state.server_mode {
2964 return StatusCode::NOT_FOUND.into_response();
2965 }
2966
2967 let upload_id = uuid::Uuid::new_v4().to_string();
2968 let upload_base = upload_base_dir();
2969 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2970 let staging = upload_staging_path(&upload_id);
2971 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2972
2973 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2974 tracing::error!(
2975 event = "upload_io_error",
2976 "failed to create upload base dir: {e}"
2977 );
2978 return (
2979 StatusCode::INTERNAL_SERVER_ERROR,
2980 Json(serde_json::json!({"error": "Upload initialization failed"})),
2981 )
2982 .into_response();
2983 }
2984
2985 let compressed_bytes =
2987 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2988 Ok(n) => n,
2989 Err(resp) => return resp,
2990 };
2991
2992 if let Err(resp) =
2994 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2995 {
2996 return resp;
2997 }
2998
2999 let scan_root = find_single_top_dir(&staging)
3004 .await
3005 .unwrap_or_else(|| staging.clone());
3006
3007 let original_bytes = tokio::task::spawn_blocking({
3009 let p = scan_root.clone();
3010 move || dir_size_bytes(&p)
3011 })
3012 .await
3013 .unwrap_or(0);
3014
3015 Json(serde_json::json!({
3016 "tmp_path": scan_root.to_string_lossy(),
3017 "upload_id": upload_id,
3018 "compressed_bytes": compressed_bytes,
3019 "original_bytes": original_bytes,
3020 }))
3021 .into_response()
3022}
3023
3024#[derive(Deserialize)]
3025struct LocateReportForm {
3026 file_path: String,
3027 #[serde(default)]
3028 redirect_url: Option<String>,
3029 #[serde(default)]
3030 expected_run_id: Option<String>,
3031}
3032
3033fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3035 let html = ErrorTemplate {
3036 message: message.into(),
3037 last_report_url: Some("/view-reports".to_string()),
3038 last_report_label: Some("View Reports".to_string()),
3039 run_id: None,
3040 error_code: None,
3041 csp_nonce: csp_nonce.to_owned(),
3042 version: env!("CARGO_PKG_VERSION"),
3043 }
3044 .render()
3045 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3046 Html(html).into_response()
3047}
3048
3049fn registry_entry_from_run(
3051 run: &AnalysisRun,
3052 json_path: PathBuf,
3053 html_path: PathBuf,
3054) -> RegistryEntry {
3055 let project_label = run.input_roots.first().map_or_else(
3056 || "Unknown Project".to_string(),
3057 |r| sanitize_project_label(r),
3058 );
3059 RegistryEntry {
3060 run_id: run.tool.run_id.clone(),
3061 timestamp_utc: run.tool.timestamp_utc,
3062 project_label,
3063 input_roots: run.input_roots.clone(),
3064 json_path: Some(json_path),
3065 html_path: Some(html_path),
3066 pdf_path: None,
3067 summary: ScanSummarySnapshot::from(&run.summary_totals),
3068 csv_path: None,
3069 xlsx_path: None,
3070 git_branch: None,
3071 git_commit: None,
3072 git_commit_long: None,
3073 git_author: None,
3074 git_tags: None,
3075 git_nearest_tag: None,
3076 git_commit_date: None,
3077 }
3078}
3079
3080pub(crate) async fn register_artifacts_in_registry(
3083 state: &AppState,
3084 label: &str,
3085 run: &AnalysisRun,
3086 artifacts: &RunArtifacts,
3087) {
3088 let Some(json_path) = artifacts.json_path.clone() else {
3089 return;
3090 };
3091 let Some(html_path) = artifacts.html_path.clone() else {
3092 return;
3093 };
3094 let mut entry = registry_entry_from_run(run, json_path, html_path);
3095 entry.project_label = label.to_owned();
3096 let mut reg = state.registry.lock().await;
3097 reg.add_entry(entry);
3098 let _ = reg.save(&state.registry_path);
3099}
3100
3101fn is_html_report_file(p: &Path) -> bool {
3102 p.is_file()
3103 && p.extension()
3104 .and_then(|x| x.to_str())
3105 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3106 && p.file_name()
3107 .and_then(|n| n.to_str())
3108 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3109}
3110
3111fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3112 fs::read_dir(dir)
3113 .ok()?
3114 .flatten()
3115 .map(|e| e.path())
3116 .find(|p| is_html_report_file(p))
3117}
3118
3119fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3120 if let Some(f) = find_html_report_in_dir(dir) {
3121 return Some(f);
3122 }
3123 if let Ok(rd) = fs::read_dir(dir) {
3124 for entry in rd.flatten() {
3125 let sub = entry.path();
3126 if sub.is_dir() {
3127 if let Some(f) = find_html_report_in_dir(&sub) {
3128 return Some(f);
3129 }
3130 }
3131 }
3132 }
3133 None
3134}
3135
3136#[allow(clippy::result_large_err)]
3141fn validate_locate_request(
3142 state: &AppState,
3143 file_path: &str,
3144 csp_nonce: &str,
3145) -> Result<(PathBuf, PathBuf), Response> {
3146 let raw = PathBuf::from(file_path);
3147
3148 let html_path = if raw.is_dir() {
3150 let found = find_html_report_in_tree(&raw);
3151 match found {
3152 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3153 None => {
3154 return Err(locate_report_error(
3155 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3156 the folder that contains your scan output (result_*.html or report_*.html).",
3157 csp_nonce,
3158 ));
3159 }
3160 }
3161 } else {
3162 let file_ext = raw
3163 .extension()
3164 .and_then(|e| e.to_str())
3165 .unwrap_or("")
3166 .to_ascii_lowercase();
3167 if file_ext != "html" {
3168 return Err(locate_report_error(
3169 "Please select the scan output folder, or an .html report file directly.",
3170 csp_nonce,
3171 ));
3172 }
3173 match fs::canonicalize(&raw) {
3174 Ok(p) => strip_unc_prefix(p),
3175 Err(_) => {
3176 return Err(locate_report_error(
3177 "Report file not found or path is invalid.",
3178 csp_nonce,
3179 ));
3180 }
3181 }
3182 };
3183
3184 if state.server_mode {
3185 let output_root = resolve_output_root(None);
3186 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3187 if !html_path.starts_with(&canonical_root) {
3188 return Err(locate_report_error(
3189 "Report file must be within the configured output directory.",
3190 csp_nonce,
3191 ));
3192 }
3193 }
3194 let parent = match html_path.parent() {
3195 Some(p) => p.to_path_buf(),
3196 None => {
3197 return Err(locate_report_error(
3198 "Report file has no parent directory.",
3199 csp_nonce,
3200 ));
3201 }
3202 };
3203 Ok((html_path, parent))
3204}
3205
3206fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3208 if want_json {
3209 (
3210 StatusCode::UNPROCESSABLE_ENTITY,
3211 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3212 )
3213 .into_response()
3214 } else {
3215 locate_report_error(msg, csp_nonce)
3216 }
3217}
3218
3219fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3221 if want_json {
3222 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3223 } else {
3224 axum::response::Redirect::to(redirect).into_response()
3225 }
3226}
3227
3228fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3231 for jpath in candidates {
3232 if let Ok(run) = read_json(jpath) {
3233 if expected.is_empty() || run.tool.run_id == expected {
3234 return Some((jpath.clone(), run.tool.run_id));
3235 }
3236 }
3237 }
3238 None
3239}
3240
3241fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3242 html_path
3243 .parent()
3244 .and_then(|p| p.parent())
3245 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3246}
3247
3248fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3249 let mut hits = collect_result_json_candidates(scan_root);
3250 if hits.is_empty() {
3251 hits = collect_result_json_candidates(parent);
3252 }
3253 hits.sort();
3254 hits
3255}
3256
3257#[allow(clippy::too_many_lines)]
3258async fn locate_report_handler(
3259 State(state): State<AppState>,
3260 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3261 headers: axum::http::HeaderMap,
3262 Form(form): Form<LocateReportForm>,
3263) -> impl IntoResponse {
3264 let want_json = headers
3265 .get(axum::http::header::ACCEPT)
3266 .and_then(|v| v.to_str().ok())
3267 .is_some_and(|v| v.contains("application/json"));
3268
3269 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3270 Ok(v) => v,
3271 Err(resp) => {
3272 if want_json {
3273 return locate_handler_err(
3274 true,
3275 "No HTML report file found in the selected folder. \
3276 Make sure you selected the folder that contains your \
3277 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3278 .to_string(),
3279 &csp_nonce,
3280 );
3281 }
3282 return resp;
3283 }
3284 };
3285
3286 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3289 let scan_root: &Path = &scan_root_owned;
3290 let json_candidates = gather_json_candidates(scan_root, &parent);
3291
3292 let expected_run_id = form
3294 .expected_run_id
3295 .as_deref()
3296 .unwrap_or("")
3297 .trim()
3298 .to_string();
3299
3300 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3301
3302 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3304 let actual = json_candidates
3305 .iter()
3306 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3307 .unwrap_or_else(|| "unknown".to_string());
3308 return locate_handler_err(
3309 want_json,
3310 format!(
3311 "This folder contains a different scan.\n\n\
3312 Expected run ID : {expected_run_id}\n\
3313 Found run ID : {actual}\n\n\
3314 Please select the folder that contains the correct scan output."
3315 ),
3316 &csp_nonce,
3317 );
3318 }
3319
3320 let safe_redirect = form
3321 .redirect_url
3322 .as_deref()
3323 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3324 .unwrap_or("/view-reports?linked=1")
3325 .to_string();
3326
3327 let mut reg = state.registry.lock().await;
3328
3329 if let Some((json_path, run_id)) = matched_json {
3330 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3332 entry.html_path = Some(html_path);
3333 entry.json_path = Some(json_path);
3334 let _ = reg.save(&state.registry_path);
3335 drop(reg);
3336 state.artifacts.lock().await.remove(&run_id);
3338 return redirect_or_json_ok(want_json, &safe_redirect);
3339 }
3340 match read_json(&json_path) {
3342 Ok(run) => {
3343 let entry = registry_entry_from_run(&run, json_path, html_path);
3344 reg.add_entry(entry);
3345 let _ = reg.save(&state.registry_path);
3346 drop(reg);
3347 state.artifacts.lock().await.remove(&run_id);
3348 return redirect_or_json_ok(want_json, &safe_redirect);
3349 }
3350 Err(e) => {
3351 drop(reg);
3352 return locate_handler_err(
3353 want_json,
3354 format!(
3355 "Found the scan folder but could not parse the result JSON.\n\n\
3356 The file may have been saved by an older version of OxideSLOC. \
3357 Re-running the analysis will create a fresh, compatible record.\n\n\
3358 Error: {e}"
3359 ),
3360 &csp_nonce,
3361 );
3362 }
3363 }
3364 }
3365
3366 if let Some(entry) = reg
3368 .entries
3369 .iter_mut()
3370 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3371 {
3372 entry.html_path = Some(html_path.clone());
3373 let _ = reg.save(&state.registry_path);
3374 drop(reg);
3375 state.artifacts.lock().await.remove(&expected_run_id);
3376 return redirect_or_json_ok(want_json, &safe_redirect);
3377 }
3378
3379 drop(reg);
3380 let hint = if state.server_mode {
3381 String::new()
3382 } else {
3383 format!(
3384 "\n\nSearched folder : {}\nHTML found : {}",
3385 scan_root.display(),
3386 html_path.display()
3387 )
3388 };
3389 locate_handler_err(
3390 want_json,
3391 format!(
3392 "Could not link this report.\n\n\
3393 No result_*.json was found in the selected folder. \
3394 Make sure you selected the top-level scan output folder \
3395 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3396 ),
3397 &csp_nonce,
3398 )
3399}
3400
3401fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3403 fs::read_dir(dir)
3404 .ok()?
3405 .flatten()
3406 .map(|e| e.path())
3407 .find(|p| {
3408 p.is_file()
3409 && p.file_stem()
3410 .and_then(|n| n.to_str())
3411 .is_some_and(|n| n.starts_with("result"))
3412 && p.extension()
3413 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3414 })
3415}
3416
3417#[derive(Deserialize)]
3418struct LocateReportsDirForm {
3419 folder_path: String,
3420}
3421
3422#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3424 State(state): State<AppState>,
3425 Form(form): Form<LocateReportsDirForm>,
3426) -> impl IntoResponse {
3427 if state.server_mode {
3428 return StatusCode::NOT_FOUND.into_response();
3429 }
3430 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3431 Ok(p) => strip_unc_prefix(p),
3432 Err(_) => {
3433 return axum::response::Redirect::to(
3434 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3435 )
3436 .into_response();
3437 }
3438 };
3439 if !folder.is_dir() {
3440 return axum::response::Redirect::to(
3441 "/view-reports?error=Selected+path+is+not+a+directory.",
3442 )
3443 .into_response();
3444 }
3445
3446 let candidates = collect_result_json_candidates(&folder);
3447
3448 if candidates.is_empty() {
3449 return axum::response::Redirect::to(
3450 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3451 )
3452 .into_response();
3453 }
3454
3455 let mut linked_count: usize = 0;
3456 let mut reg = state.registry.lock().await;
3457 for json_path in candidates {
3458 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3459 continue;
3460 };
3461 if is_dir_already_registered(®, &parent) {
3462 continue;
3463 }
3464 let Some(entry) = build_registry_entry_from_json(json_path) else {
3465 continue;
3466 };
3467 reg.add_entry(entry);
3468 linked_count += 1;
3469 }
3470 let _ = reg.save(&state.registry_path);
3471 drop(reg);
3472
3473 if linked_count == 0 {
3474 return axum::response::Redirect::to(
3475 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3476 )
3477 .into_response();
3478 }
3479 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3480}
3481
3482#[derive(Deserialize)]
3483struct RelocateScanForm {
3484 run_id: String,
3485 folder_path: String,
3486 redirect_url: String,
3487}
3488
3489fn relocate_folder_err(
3492 want_json: bool,
3493 status: StatusCode,
3494 msg: &str,
3495 run_id: &str,
3496 folder_hint: &str,
3497 redirect_url: &str,
3498 csp_nonce: &str,
3499) -> Response {
3500 if want_json {
3501 (
3502 status,
3503 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3504 )
3505 .into_response()
3506 } else {
3507 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3508 }
3509}
3510
3511#[allow(clippy::too_many_lines)]
3512async fn relocate_scan_handler(
3513 State(state): State<AppState>,
3514 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3515 headers: axum::http::HeaderMap,
3516 Form(form): Form<RelocateScanForm>,
3517) -> impl IntoResponse {
3518 let want_json = headers
3519 .get(axum::http::header::ACCEPT)
3520 .and_then(|v| v.to_str().ok())
3521 .is_some_and(|v| v.contains("application/json"));
3522 if state.server_mode {
3523 return StatusCode::NOT_FOUND.into_response();
3524 }
3525
3526 let run_id = form.run_id.trim().to_string();
3527 let redirect_url = form.redirect_url.trim().to_string();
3528
3529 let run_exists = {
3530 let reg = state.registry.lock().await;
3531 reg.find_by_run_id(&run_id).is_some()
3532 };
3533 if !run_exists {
3534 if want_json {
3535 return (
3536 StatusCode::NOT_FOUND,
3537 axum::Json(serde_json::json!({
3538 "ok": false,
3539 "message": format!("Run ID '{run_id}' not found in registry.")
3540 })),
3541 )
3542 .into_response();
3543 }
3544 let html = ErrorTemplate {
3545 message: format!("Run ID '{run_id}' not found in registry."),
3546 last_report_url: Some("/compare-scans".to_string()),
3547 last_report_label: Some("Compare Scans".to_string()),
3548 run_id: Some(run_id.clone()),
3549 error_code: Some(404),
3550 csp_nonce: csp_nonce.clone(),
3551 version: env!("CARGO_PKG_VERSION"),
3552 }
3553 .render()
3554 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3555 return Html(html).into_response();
3556 }
3557
3558 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3559 Ok(p) => strip_unc_prefix(p),
3560 Err(_) => {
3561 return relocate_folder_err(
3562 want_json,
3563 StatusCode::UNPROCESSABLE_ENTITY,
3564 "Folder not found or path is invalid.",
3565 &run_id,
3566 form.folder_path.trim(),
3567 &redirect_url,
3568 &csp_nonce,
3569 );
3570 }
3571 };
3572 if !folder.is_dir() {
3573 return relocate_folder_err(
3574 want_json,
3575 StatusCode::UNPROCESSABLE_ENTITY,
3576 "Selected path is not a directory.",
3577 &run_id,
3578 &folder.display().to_string(),
3579 &redirect_url,
3580 &csp_nonce,
3581 );
3582 }
3583
3584 let json_candidates = find_result_files_by_ext(&folder, "json");
3585 if json_candidates.is_empty() {
3586 let msg = format!(
3587 "No result JSON files found in the selected folder.\nSearched: {}",
3588 folder.display()
3589 );
3590 return relocate_folder_err(
3591 want_json,
3592 StatusCode::UNPROCESSABLE_ENTITY,
3593 &msg,
3594 &run_id,
3595 &folder.display().to_string(),
3596 &redirect_url,
3597 &csp_nonce,
3598 );
3599 }
3600
3601 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3602 let msg = format!(
3603 "No matching scan found in the selected folder.\n\
3604 The JSON files present do not contain run ID: {run_id}\n\
3605 Searched: {}",
3606 folder.display()
3607 );
3608 return relocate_folder_err(
3609 want_json,
3610 StatusCode::UNPROCESSABLE_ENTITY,
3611 &msg,
3612 &run_id,
3613 &folder.display().to_string(),
3614 &redirect_url,
3615 &csp_nonce,
3616 );
3617 };
3618
3619 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3620 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3621 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3622
3623 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3624 redirect_url
3625 } else {
3626 "/compare-scans".to_string()
3627 };
3628 redirect_or_json_ok(want_json, &safe_redirect)
3629}
3630
3631fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3632 let mut out = Vec::new();
3633 collect_scan_files_by_ext(folder, ext, &mut out);
3634 if let Ok(rd) = fs::read_dir(folder) {
3635 for entry in rd.flatten() {
3636 let sub = entry.path();
3637 if sub.is_dir() {
3638 collect_scan_files_by_ext(&sub, ext, &mut out);
3639 }
3640 }
3641 }
3642 out
3643}
3644
3645fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3646 let Ok(rd) = fs::read_dir(dir) else { return };
3647 for entry in rd.flatten() {
3648 let p = entry.path();
3649 if p.is_file()
3650 && p.file_stem()
3651 .and_then(|n| n.to_str())
3652 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3653 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3654 {
3655 out.push(p);
3656 }
3657 }
3658}
3659
3660fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3661 candidates
3662 .iter()
3663 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3664 .cloned()
3665}
3666
3667fn output_folder_hint(json_path: &std::path::Path) -> String {
3672 let Some(direct_parent) = json_path.parent() else {
3673 return String::new();
3674 };
3675 let parent_name = direct_parent
3676 .file_name()
3677 .and_then(|n| n.to_str())
3678 .unwrap_or("");
3679 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3680 direct_parent.parent().map_or_else(
3681 || direct_parent.display().to_string(),
3682 |p| p.display().to_string(),
3683 )
3684 } else {
3685 direct_parent.display().to_string()
3686 }
3687}
3688
3689async fn update_run_file_paths(
3690 state: &AppState,
3691 run_id: &str,
3692 json_path: PathBuf,
3693 html_path: Option<PathBuf>,
3694 pdf_path: Option<PathBuf>,
3695) {
3696 {
3697 let mut reg = state.registry.lock().await;
3698 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3699 entry.json_path = Some(json_path.clone());
3700 if let Some(ref hp) = html_path {
3701 entry.html_path = Some(hp.clone());
3702 }
3703 if let Some(ref pp) = pdf_path {
3704 entry.pdf_path = Some(pp.clone());
3705 }
3706 }
3707 let _ = reg.save(&state.registry_path);
3708 }
3709 {
3712 let mut map = state.artifacts.lock().await;
3713 if let Some(arts) = map.get_mut(run_id) {
3714 arts.json_path = Some(json_path);
3715 if let Some(hp) = html_path {
3716 arts.html_path = Some(hp);
3717 }
3718 if let Some(pp) = pdf_path {
3719 arts.pdf_path = Some(pp);
3720 }
3721 }
3722 }
3723}
3724
3725fn missing_scan_relocate_response(
3726 message: &str,
3727 run_id: &str,
3728 folder_hint: &str,
3729 redirect_url: &str,
3730 server_mode: bool,
3731 csp_nonce: &str,
3732) -> axum::response::Response {
3733 let html = RelocateScanTemplate {
3734 message: message.to_string(),
3735 run_id: run_id.to_string(),
3736 folder_hint: folder_hint.to_string(),
3737 redirect_url: redirect_url.to_string(),
3738 server_mode,
3739 csp_nonce: csp_nonce.to_owned(),
3740 version: env!("CARGO_PKG_VERSION"),
3741 }
3742 .render()
3743 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3744 (StatusCode::NOT_FOUND, Html(html)).into_response()
3745}
3746
3747fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3751 fs::read_dir(dir)
3752 .ok()?
3753 .flatten()
3754 .map(|e| e.path())
3755 .find(|p| {
3756 p.is_file()
3757 && p.extension()
3758 .and_then(|e| e.to_str())
3759 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3760 })
3761}
3762
3763fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3767 let mut out = Vec::new();
3768 if let Some(j) = find_result_json_in_dir(sub) {
3769 out.push(j);
3770 }
3771 let json_sub = sub.join("json");
3772 if json_sub.is_dir() {
3773 if let Some(j) = find_result_json_in_dir(&json_sub) {
3774 out.push(j);
3775 }
3776 }
3777 out
3778}
3779
3780fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3781 let mut candidates = Vec::new();
3782 if let Some(j) = find_result_json_in_dir(folder) {
3783 candidates.push(j);
3784 }
3785 let Ok(dir_entries) = fs::read_dir(folder) else {
3786 return candidates;
3787 };
3788 for entry in dir_entries.flatten() {
3789 let sub = entry.path();
3790 if sub.is_dir() {
3791 candidates.extend(subdir_result_json_candidates(&sub));
3792 }
3793 }
3794 candidates
3795}
3796
3797fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3798 reg.entries.iter().any(|e| {
3799 let dir_match = e
3800 .json_path
3801 .as_ref()
3802 .and_then(|p| p.parent())
3803 .is_some_and(|p| p == parent)
3804 || e.html_path
3805 .as_ref()
3806 .and_then(|p| p.parent())
3807 .is_some_and(|p| p == parent);
3808 dir_match
3809 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3810 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3811 })
3812}
3813
3814fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3815 let json_dir = json_path.parent()?.to_path_buf();
3816 let (html_path, pdf_path, csv_path, xlsx_path) =
3819 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3820 let scan_root = json_dir.parent()?;
3821 let html = find_html_report_in_dir(&scan_root.join("html"))
3822 .or_else(|| find_html_report_in_dir(scan_root));
3823 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3824 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3825 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3826 (html, pdf, csv, xlsx)
3827 } else {
3828 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3829 rd.flatten()
3830 .map(|e| e.path())
3831 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3832 });
3833 (html, None, None, None)
3834 };
3835 let run = read_json(&json_path).ok()?;
3836 let project_label = run.input_roots.first().map_or_else(
3837 || "Unknown Project".to_string(),
3838 |r| sanitize_project_label(r),
3839 );
3840 Some(RegistryEntry {
3841 run_id: run.tool.run_id.clone(),
3842 timestamp_utc: run.tool.timestamp_utc,
3843 project_label,
3844 input_roots: run.input_roots.clone(),
3845 json_path: Some(json_path),
3846 html_path,
3847 pdf_path,
3848 csv_path,
3849 xlsx_path,
3850 summary: ScanSummarySnapshot::from(&run.summary_totals),
3851 git_branch: run.git_branch.clone(),
3852 git_commit: run.git_commit_short.clone(),
3853 git_commit_long: run.git_commit_long.clone(),
3854 git_author: run.git_commit_author.clone(),
3855 git_tags: run.git_tags.clone(),
3856 git_nearest_tag: run.git_nearest_tag.clone(),
3857 git_commit_date: run.git_commit_date,
3858 })
3859}
3860
3861fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3864 let mut linked = 0usize;
3865 for json_path in collect_result_json_candidates(folder) {
3866 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3867 continue;
3868 };
3869 if is_dir_already_registered(reg, &parent) {
3870 continue;
3871 }
3872 let Some(entry) = build_registry_entry_from_json(json_path) else {
3873 continue;
3874 };
3875 reg.add_entry(entry);
3876 linked += 1;
3877 }
3878 linked
3879}
3880
3881async fn auto_scan_watched_dirs(state: &AppState) {
3883 let dirs: Vec<PathBuf> = {
3884 let wd = state.watched_dirs.lock().await;
3885 wd.dirs.clone()
3886 };
3887 if dirs.is_empty() {
3888 return;
3889 }
3890 let mut reg = state.registry.lock().await;
3891 let mut total = 0usize;
3892 for dir in &dirs {
3893 if dir.is_dir() {
3894 total += scan_folder_into_registry(dir, &mut reg);
3895 }
3896 }
3897 if total > 0 {
3898 let _ = reg.save(&state.registry_path);
3899 }
3900}
3901
3902#[derive(Deserialize)]
3905struct WatchedDirForm {
3906 folder_path: String,
3907 #[serde(default = "default_redirect")]
3908 redirect_to: String,
3909}
3910
3911fn default_redirect() -> String {
3912 "/view-reports".to_string()
3913}
3914
3915#[derive(Deserialize)]
3916struct WatchedDirRefreshForm {
3917 #[serde(default = "default_redirect")]
3918 redirect_to: String,
3919}
3920
3921fn safe_redirect(dest: &str) -> &str {
3925 if dest.starts_with('/') {
3926 dest
3927 } else {
3928 "/"
3929 }
3930}
3931
3932async fn add_watched_dir_handler(
3935 State(state): State<AppState>,
3936 Form(form): Form<WatchedDirForm>,
3937) -> impl IntoResponse {
3938 if state.server_mode {
3939 return StatusCode::NOT_FOUND.into_response();
3940 }
3941 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3942 strip_unc_prefix(p)
3943 } else {
3944 let dest = format!(
3945 "{}?error=Folder+not+found+or+path+is+invalid.",
3946 safe_redirect(&form.redirect_to)
3947 );
3948 return axum::response::Redirect::to(&dest).into_response();
3949 };
3950 if !folder.is_dir() {
3951 let dest = format!(
3952 "{}?error=Selected+path+is+not+a+directory.",
3953 safe_redirect(&form.redirect_to)
3954 );
3955 return axum::response::Redirect::to(&dest).into_response();
3956 }
3957
3958 {
3960 let mut wd = state.watched_dirs.lock().await;
3961 wd.add(folder.clone());
3962 let _ = wd.save(&state.watched_dirs_path);
3963 }
3964
3965 let linked = {
3967 let mut reg = state.registry.lock().await;
3968 let n = scan_folder_into_registry(&folder, &mut reg);
3969 if n > 0 {
3970 let _ = reg.save(&state.registry_path);
3971 }
3972 n
3973 };
3974
3975 let dest = if linked > 0 {
3976 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3977 } else {
3978 format!(
3979 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3980 safe_redirect(&form.redirect_to)
3981 )
3982 };
3983 axum::response::Redirect::to(&dest).into_response()
3984}
3985
3986async fn remove_watched_dir_handler(
3987 State(state): State<AppState>,
3988 Form(form): Form<WatchedDirForm>,
3989) -> impl IntoResponse {
3990 if state.server_mode {
3991 return StatusCode::NOT_FOUND.into_response();
3992 }
3993 let folder = PathBuf::from(&form.folder_path);
3994 {
3995 let mut wd = state.watched_dirs.lock().await;
3996 wd.remove(&folder);
3997 let _ = wd.save(&state.watched_dirs_path);
3998 }
3999 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4000}
4001
4002async fn refresh_watched_dirs_handler(
4003 State(state): State<AppState>,
4004 Form(form): Form<WatchedDirRefreshForm>,
4005) -> impl IntoResponse {
4006 if state.server_mode {
4007 return StatusCode::NOT_FOUND.into_response();
4008 }
4009 let dirs: Vec<PathBuf> = {
4010 let wd = state.watched_dirs.lock().await;
4011 wd.dirs.clone()
4012 };
4013 let mut total = 0usize;
4014 {
4015 let mut reg = state.registry.lock().await;
4016 reg.prune_stale();
4017 for dir in &dirs {
4018 if dir.is_dir() {
4019 total += scan_folder_into_registry(dir, &mut reg);
4020 }
4021 }
4022 let _ = reg.save(&state.registry_path);
4023 }
4024 let dest = if total > 0 {
4025 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4026 } else {
4027 safe_redirect(&form.redirect_to).to_owned()
4028 };
4029 axum::response::Redirect::to(&dest).into_response()
4030}
4031
4032#[derive(Debug, Deserialize)]
4033struct OpenPathQuery {
4034 path: Option<String>,
4035}
4036
4037fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4038 let mut ancestor = std::path::Path::new(raw);
4039 loop {
4040 match ancestor.parent() {
4041 Some(p) => {
4042 ancestor = p;
4043 if ancestor.is_dir() {
4044 break;
4045 }
4046 }
4047 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4048 }
4049 }
4050 Ok(ancestor.to_path_buf())
4051}
4052
4053async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4054 match tokio::fs::canonicalize(raw).await {
4055 Ok(canonical) if canonical.is_file() => canonical
4056 .parent()
4057 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4058 Ok(p.to_path_buf())
4059 }),
4060 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4061 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4062 Err(_) => find_existing_ancestor(raw),
4063 }
4064}
4065
4066async fn open_path_handler(
4067 State(state): State<AppState>,
4068 Query(query): Query<OpenPathQuery>,
4069) -> impl IntoResponse {
4070 if state.server_mode {
4071 return Json(serde_json::json!({
4072 "server_mode_disabled": true,
4073 "message": "Opening a path in the file manager is only available in local desktop mode."
4074 }))
4075 .into_response();
4076 }
4077 if std::env::var("SLOC_HEADLESS").is_ok() {
4079 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4080 }
4081 let raw = match query.path.as_deref() {
4082 Some(p) if !p.is_empty() => p,
4083 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4084 };
4085
4086 let target = match resolve_open_target(raw).await {
4090 Ok(p) => p,
4091 Err((code, msg)) => return (code, msg).into_response(),
4092 };
4093
4094 #[cfg(target_os = "windows")]
4095 win_dialog_focus::open_folder_foreground(target);
4096 #[cfg(target_os = "macos")]
4097 let _ = std::process::Command::new("open")
4098 .arg(&target)
4099 .stdout(Stdio::null())
4100 .stderr(Stdio::null())
4101 .spawn();
4102 #[cfg(target_os = "linux")]
4103 {
4104 let folder_name = target
4105 .file_name()
4106 .and_then(|n| n.to_str())
4107 .map(str::to_owned);
4108 let _ = std::process::Command::new("xdg-open")
4109 .arg(&target)
4110 .stdout(Stdio::null())
4111 .stderr(Stdio::null())
4112 .spawn();
4113 if let Some(name) = folder_name {
4117 std::thread::spawn(move || {
4118 std::thread::sleep(std::time::Duration::from_millis(800));
4119 let _ = std::process::Command::new("wmctrl")
4120 .args(["-a", &name])
4121 .stdout(Stdio::null())
4122 .stderr(Stdio::null())
4123 .spawn();
4124 });
4125 }
4126 }
4127
4128 Json(serde_json::json!({"ok": true})).into_response()
4129}
4130
4131async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4132 let (content_type, bytes): (&'static str, &'static [u8]) =
4133 match (folder.as_str(), file.as_str()) {
4134 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4135 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4136 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4137 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4138 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4139 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4140 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4141 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4142 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4143 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4144 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4145 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4146 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4147 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4148 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4149 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4150 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4151 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4152 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4153 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4154 _ => return StatusCode::NOT_FOUND.into_response(),
4155 };
4156 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4157}
4158
4159fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4164 let Ok(canonical) = fs::canonicalize(resolved) else {
4169 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4170 return Err(Html(
4171 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4172 ));
4173 }
4174 return Ok(());
4175 };
4176 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4178 return Ok(());
4179 }
4180 let config = &state.base_config;
4181 if config.discovery.allowed_scan_roots.is_empty() {
4182 return Err(Html(
4183 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4184 ));
4185 }
4186 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4187 fs::canonicalize(root)
4188 .ok()
4189 .is_some_and(|r| canonical.starts_with(&r))
4190 });
4191 if !allowed {
4192 return Err(Html(
4193 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4194 ));
4195 }
4196 Ok(())
4197}
4198
4199async fn preview_handler(
4200 State(state): State<AppState>,
4201 Query(query): Query<PreviewQuery>,
4202) -> impl IntoResponse {
4203 let raw_path = query
4204 .path
4205 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
4206 let resolved = resolve_input_path(&raw_path);
4207
4208 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4212 return Html(
4213 r#"<div class="preview-error">Sample directory not available on this server.
4214 Enter a path to a project directory or upload files using Browse.</div>"#
4215 .to_string(),
4216 );
4217 }
4218
4219 if state.server_mode {
4220 if let Err(resp) = authorize_preview_path(&state, &resolved) {
4221 return resp;
4222 }
4223 }
4224
4225 let include_patterns = split_patterns(query.include_globs.as_deref());
4226 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4227
4228 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4229 Ok(html) => Html(html),
4230 Err(err) => Html(format!(
4231 r#"<div class="preview-error">Preview failed: {}</div>"#,
4232 escape_html(&err.to_string())
4233 )),
4234 }
4235}
4236
4237#[derive(Debug, Deserialize, Default)]
4238struct SuggestCoverageQuery {
4239 path: Option<String>,
4240}
4241
4242#[derive(Serialize)]
4243struct SuggestCoverageResponse {
4244 found: Option<String>,
4245 tool: Option<&'static str>,
4246 hint: Option<&'static str>,
4247}
4248
4249async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4250 const CANDIDATES: &[&str] = &[
4251 "coverage/lcov.info",
4253 "lcov.info",
4254 "target/llvm-cov/lcov.info",
4255 "target/coverage/lcov.info",
4256 "target/debug/coverage/lcov.info",
4257 "coverage/coverage.lcov",
4258 "build/coverage/lcov.info",
4259 "reports/lcov.info",
4260 "coverage.xml",
4262 "coverage/coverage.xml",
4263 "target/site/cobertura/coverage.xml",
4264 "build/reports/coverage/coverage.xml",
4265 "target/site/jacoco/jacoco.xml",
4267 "build/reports/jacoco/test/jacocoTestReport.xml",
4268 "build/reports/jacoco/jacocoTestReport.xml",
4269 "build/jacoco/jacoco.xml",
4270 "coverage.json",
4272 "coverage/coverage.json",
4273 ];
4274 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4275 let found = CANDIDATES
4276 .iter()
4277 .map(|rel| root.join(rel))
4278 .find(|p| p.is_file())
4279 .map(|p| display_path(&p));
4280
4281 let (tool, hint) = detect_coverage_tool(&root);
4282 Json(SuggestCoverageResponse { found, tool, hint })
4283}
4284
4285fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4288 if root.join("Cargo.toml").is_file() {
4289 return (
4290 Some("cargo-llvm-cov"),
4291 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4292 );
4293 }
4294 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4295 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4296 }
4297 if root.join("pom.xml").is_file() {
4298 return (Some("jacoco"), Some("mvn test jacoco:report"));
4299 }
4300 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4301 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4302 }
4303 (None, None)
4304}
4305
4306#[allow(clippy::result_large_err)]
4308fn validate_server_scan_path(
4309 config: &sloc_config::AppConfig,
4310 resolved_path: &Path,
4311 csp_nonce: &str,
4312) -> Result<(), Response> {
4313 if config.discovery.allowed_scan_roots.is_empty() {
4314 let template = ErrorTemplate {
4315 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4316 Set allowed_scan_roots in the server config to permit scanning."
4317 .to_string(),
4318 last_report_url: None,
4319 last_report_label: None,
4320 run_id: None,
4321 error_code: Some(403),
4322 csp_nonce: csp_nonce.to_owned(),
4323 version: env!("CARGO_PKG_VERSION"),
4324 };
4325 return Err((
4326 StatusCode::FORBIDDEN,
4327 Html(
4328 template
4329 .render()
4330 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4331 ),
4332 )
4333 .into_response());
4334 }
4335 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4340 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4341 "Scan path does not resolve to a real location");
4342 let template = ErrorTemplate {
4343 message: "The requested path could not be resolved to a real directory.".to_string(),
4344 last_report_url: None,
4345 last_report_label: None,
4346 run_id: None,
4347 error_code: Some(403),
4348 csp_nonce: csp_nonce.to_owned(),
4349 version: env!("CARGO_PKG_VERSION"),
4350 };
4351 return Err((
4352 StatusCode::FORBIDDEN,
4353 Html(
4354 template
4355 .render()
4356 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4357 ),
4358 )
4359 .into_response());
4360 };
4361 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4362 fs::canonicalize(root)
4363 .ok()
4364 .is_some_and(|r| canonical.starts_with(&r))
4365 });
4366 if !allowed {
4367 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4368 "Scan path not in allowed_scan_roots");
4369 let template = ErrorTemplate {
4370 message: "The requested path is not within an allowed scan directory.".to_string(),
4371 last_report_url: None,
4372 last_report_label: None,
4373 run_id: None,
4374 error_code: Some(403),
4375 csp_nonce: csp_nonce.to_owned(),
4376 version: env!("CARGO_PKG_VERSION"),
4377 };
4378 return Err((
4379 StatusCode::FORBIDDEN,
4380 Html(
4381 template
4382 .render()
4383 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4384 ),
4385 )
4386 .into_response());
4387 }
4388 Ok(())
4389}
4390
4391fn apply_output_dir_exclusions(
4393 config: &mut sloc_config::AppConfig,
4394 project_path: &str,
4395 raw_output_dir: &str,
4396) {
4397 let project_root = resolve_input_path(project_path);
4398 let raw_out = raw_output_dir.trim();
4399 let resolved_out = if raw_out.is_empty() {
4400 project_root.join("sloc")
4401 } else if Path::new(raw_out).is_absolute() {
4402 PathBuf::from(raw_out)
4403 } else {
4404 workspace_root().join(raw_out)
4405 };
4406 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4407 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4408 let dir = first.to_string();
4409 if !config.discovery.excluded_directories.contains(&dir) {
4410 config.discovery.excluded_directories.push(dir);
4411 }
4412 }
4413 }
4414 if !config
4415 .discovery
4416 .excluded_directories
4417 .iter()
4418 .any(|d| d == "sloc")
4419 {
4420 config
4421 .discovery
4422 .excluded_directories
4423 .push("sloc".to_string());
4424 }
4425}
4426
4427const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4429 ScanSummarySnapshot {
4430 files_analyzed: run.summary_totals.files_analyzed,
4431 files_skipped: run.summary_totals.files_skipped,
4432 total_physical_lines: run.summary_totals.total_physical_lines,
4433 code_lines: run.summary_totals.code_lines,
4434 comment_lines: run.summary_totals.comment_lines,
4435 blank_lines: run.summary_totals.blank_lines,
4436 functions: run.summary_totals.functions,
4437 classes: run.summary_totals.classes,
4438 variables: run.summary_totals.variables,
4439 imports: run.summary_totals.imports,
4440 test_count: run.summary_totals.test_count,
4441 coverage_lines_found: run.summary_totals.coverage_lines_found,
4442 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4443 coverage_functions_found: run.summary_totals.coverage_functions_found,
4444 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4445 coverage_branches_found: run.summary_totals.coverage_branches_found,
4446 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4447 }
4448}
4449
4450pub(crate) fn build_run_registry_entry(
4452 run: &AnalysisRun,
4453 run_id: &str,
4454 project_label: &str,
4455 artifacts: &RunArtifacts,
4456) -> RegistryEntry {
4457 RegistryEntry {
4458 run_id: run_id.to_owned(),
4459 timestamp_utc: run.tool.timestamp_utc,
4460 project_label: project_label.to_owned(),
4461 input_roots: run.input_roots.clone(),
4462 json_path: artifacts.json_path.clone(),
4463 html_path: artifacts.html_path.clone(),
4464 pdf_path: artifacts.pdf_path.clone(),
4465 csv_path: artifacts.csv_path.clone(),
4466 xlsx_path: artifacts.xlsx_path.clone(),
4467 summary: summary_snapshot_from_run(run),
4468 git_branch: run.git_branch.clone(),
4469 git_commit: run.git_commit_short.clone(),
4470 git_commit_long: run.git_commit_long.clone(),
4471 git_author: run.git_commit_author.clone(),
4472 git_tags: run.git_tags.clone(),
4473 git_nearest_tag: run.git_nearest_tag.clone(),
4474 git_commit_date: run.git_commit_date.clone(),
4475 }
4476}
4477
4478fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4480 if let Some(policy) = form.mixed_line_policy {
4481 config.analysis.mixed_line_policy = policy;
4482 }
4483 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4484 config.analysis.generated_file_detection =
4485 form.generated_file_detection.as_deref() != Some("disabled");
4486 config.analysis.minified_file_detection =
4487 form.minified_file_detection.as_deref() != Some("disabled");
4488 config.analysis.vendor_directory_detection =
4489 form.vendor_directory_detection.as_deref() != Some("disabled");
4490 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4491 if let Some(binary_behavior) = form.binary_file_behavior {
4492 config.analysis.binary_file_behavior = binary_behavior;
4493 }
4494 apply_report_opts(config, form);
4495 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4496 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4497 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4498 if let Some(policy) = form.continuation_line_policy {
4499 config.analysis.continuation_line_policy = policy;
4500 }
4501 if let Some(policy) = form.blank_in_block_comment_policy {
4502 config.analysis.blank_in_block_comment_policy = policy;
4503 }
4504 config.analysis.count_compiler_directives =
4505 form.count_compiler_directives.as_deref() != Some("disabled");
4506 apply_style_threshold(config, form);
4507 apply_coverage_path(config, form);
4508}
4509
4510fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4511 if let Some(report_title) = form.report_title.as_deref() {
4512 let trimmed = report_title.trim();
4513 if !trimmed.is_empty() {
4514 config.reporting.report_title = trimmed.to_string();
4515 }
4516 }
4517 if let Some(hf) = form.report_header_footer.as_deref() {
4518 let trimmed = hf.trim();
4519 config.reporting.report_header_footer = if trimmed.is_empty() {
4520 None
4521 } else {
4522 Some(trimmed.to_string())
4523 };
4524 }
4525}
4526
4527fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4528 apply_style_col_threshold(config, form);
4529 apply_style_analysis_enabled(config, form);
4530 apply_style_score_threshold(config, form);
4531 apply_style_lang_scope(config, form);
4532 apply_activity_window(config, form);
4533}
4534
4535fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4536 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4537 if let Ok(t) = threshold_str.parse::<u16>() {
4538 if t == 80 || t == 100 || t == 120 {
4539 config.analysis.style_col_threshold = t;
4540 }
4541 }
4542 }
4543}
4544
4545fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4546 if let Some(v) = form.style_analysis_enabled.as_deref() {
4547 config.analysis.style_analysis_enabled = v != "disabled";
4548 }
4549}
4550
4551fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4552 if let Some(v) = form.style_score_threshold.as_deref() {
4553 if let Ok(t) = v.parse::<u8>() {
4554 config.analysis.style_score_threshold = t.min(100);
4555 }
4556 }
4557}
4558
4559fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4560 if let Some(v) = form.style_lang_scope.as_deref() {
4561 let scope = v.trim();
4562 if scope == "c_family" || scope == "all" {
4563 config.analysis.style_lang_scope = scope.to_string();
4564 }
4565 }
4566}
4567
4568fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4569 if let Some(w) = form.activity_window.as_deref() {
4572 let w = w.trim();
4573 if !w.is_empty() {
4574 if let Ok(days) = w.parse::<u32>() {
4575 config.analysis.activity_window_days = Some(days);
4576 }
4577 }
4578 }
4579}
4580
4581fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4582 if let Some(cov) = &form.coverage_file {
4583 let trimmed = cov.trim();
4584 if !trimmed.is_empty() {
4585 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4586 }
4587 }
4588}
4589
4590fn spawn_pdf_background(
4594 pending_pdf: PendingPdf,
4595 run_id: String,
4596 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4597) {
4598 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4599 tokio::spawn(async move {
4600 let result = tokio::task::spawn_blocking(move || {
4601 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4602 if cleanup_src {
4603 let _ = fs::remove_file(&pdf_src);
4604 }
4605 r
4606 })
4607 .await;
4608 let failed = match result {
4609 Ok(Ok(())) => false,
4610 Ok(Err(err)) => {
4611 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4612 true
4613 }
4614 Err(err) => {
4615 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4616 true
4617 }
4618 };
4619 if failed {
4620 let mut map = artifacts.lock().await;
4621 if let Some(entry) = map.get_mut(&run_id) {
4622 entry.pdf_path = None;
4623 }
4624 }
4625 });
4626 }
4627}
4628
4629fn spawn_native_pdf_background(
4633 json_path: PathBuf,
4634 pdf_dest: PathBuf,
4635 run_id: String,
4636 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4637) {
4638 tokio::spawn(async move {
4639 let result = tokio::task::spawn_blocking(move || {
4640 let run = sloc_core::read_json(&json_path)?;
4641 write_pdf_from_run(&run, &pdf_dest)
4642 })
4643 .await;
4644 let failed = match result {
4645 Ok(Ok(())) => false,
4646 Ok(Err(err)) => {
4647 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4648 true
4649 }
4650 Err(err) => {
4651 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4652 true
4653 }
4654 };
4655 if failed {
4656 let mut map = artifacts.lock().await;
4657 if let Some(entry) = map.get_mut(&run_id) {
4658 entry.pdf_path = None;
4659 }
4660 }
4661 });
4662}
4663
4664fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4666 cmp.file_deltas
4667 .iter()
4668 .map(|f| match f.status {
4669 FileChangeStatus::Added => f.current_code,
4670 FileChangeStatus::Modified => f.code_delta.max(0),
4671 _ => 0,
4672 })
4673 .sum()
4674}
4675
4676fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4678 cmp.file_deltas
4679 .iter()
4680 .map(|f| match f.status {
4681 FileChangeStatus::Removed => f.baseline_code,
4682 FileChangeStatus::Modified => (-f.code_delta).max(0),
4683 _ => 0,
4684 })
4685 .sum()
4686}
4687
4688fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4690 cmp.file_deltas
4691 .iter()
4692 .filter(|f| f.status == FileChangeStatus::Unchanged)
4693 .map(|f| f.current_code)
4694 .sum()
4695}
4696
4697fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4699 cmp.file_deltas
4700 .iter()
4701 .filter(|f| f.status == FileChangeStatus::Modified)
4702 .map(|f| f.current_code)
4703 .sum()
4704}
4705
4706fn build_submodule_row(
4708 s: &sloc_core::SubmoduleSummary,
4709 run: &AnalysisRun,
4710 run_id: &str,
4711 run_dir: &Path,
4712) -> SubmoduleRow {
4713 let safe = sanitize_project_label(&s.name);
4714 let artifact_key = format!("sub_{safe}");
4715 let pdf_artifact_key = format!("sub_{safe}_pdf");
4716 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4717 let parent_path = run
4718 .input_roots
4719 .first()
4720 .map_or("", std::string::String::as_str);
4721 let sub_run = build_sub_run(run, s, parent_path);
4722 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4723 render_sub_report_html(&sub_run, Some(&pdf_server_url))
4724 .ok()
4725 .and_then(|sub_html| {
4726 let sub_dir = run_dir.join("submodules");
4727 let _ = fs::create_dir_all(&sub_dir);
4728 let html_path = sub_dir.join(format!("{artifact_key}.html"));
4729 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4730 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4733 let _ = write_pdf_from_run(&sub_run, &pdf_path);
4734 Some(format!("/runs/{artifact_key}/{run_id}"))
4735 } else {
4736 None
4737 }
4738 })
4739 } else {
4740 None
4741 };
4742 SubmoduleRow {
4743 name: s.name.clone(),
4744 relative_path: s.relative_path.clone(),
4745 files_analyzed: s.files_analyzed,
4746 code_lines: s.code_lines,
4747 comment_lines: s.comment_lines,
4748 blank_lines: s.blank_lines,
4749 total_physical_lines: s.total_physical_lines,
4750 html_url,
4751 }
4752}
4753
4754#[allow(clippy::similar_names)]
4757#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4759async fn analyze_handler(
4760 State(state): State<AppState>,
4761 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4762 Form(form): Form<AnalyzeForm>,
4763) -> impl IntoResponse {
4764 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4765 let template = ErrorTemplate {
4766 message: format!(
4767 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4768 Please wait a moment and try again."
4769 ),
4770 last_report_url: None,
4771 last_report_label: None,
4772 run_id: None,
4773 error_code: Some(503),
4774 csp_nonce: csp_nonce.clone(),
4775 version: env!("CARGO_PKG_VERSION"),
4776 };
4777 return (
4778 StatusCode::SERVICE_UNAVAILABLE,
4779 Html(
4780 template
4781 .render()
4782 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4783 ),
4784 )
4785 .into_response();
4786 };
4787
4788 let mut config = state.base_config.clone();
4789
4790 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4791 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4792 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4793
4794 if !is_git_mode {
4795 let resolved_path = resolve_input_path(&form.path);
4796 if state.server_mode
4797 && !is_upload_tmp_path(&resolved_path)
4798 && !is_sample_path(&resolved_path)
4799 {
4800 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4801 return resp;
4802 }
4803 }
4804 config.discovery.root_paths = vec![resolved_path];
4805 }
4806
4807 apply_form_to_config(&mut config, &form);
4808 apply_output_dir_exclusions(
4809 &mut config,
4810 &form.path,
4811 form.output_dir.as_deref().unwrap_or(""),
4812 );
4813
4814 let wait_id = uuid::Uuid::new_v4().to_string();
4816 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4817
4818 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4820 let task_cancel = Arc::clone(&cancel_token);
4821
4822 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4824 let task_phase = Arc::clone(&phase);
4825
4826 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4827 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4828 let task_files_done = Arc::clone(&files_done);
4829 let task_files_total = Arc::clone(&files_total);
4830
4831 {
4834 let mut runs = state.async_runs.lock().await;
4835 runs.insert(
4836 wait_id.clone(),
4837 AsyncRunState::Running {
4838 started_at: std::time::Instant::now(),
4839 cancel_token,
4840 phase,
4841 files_done,
4842 files_total,
4843 },
4844 );
4845 }
4846
4847 let task = AnalysisTask {
4848 sem_permit,
4849 state: state.clone(),
4850 wait_id: wait_id.clone(),
4851 config,
4852 cancel: task_cancel,
4853 phase: task_phase,
4854 files_done: task_files_done,
4855 files_total: task_files_total,
4856 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4857 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4858 project_path: form.path.clone(),
4859 output_dir: if state.server_mode {
4863 None
4864 } else {
4865 form.output_dir.clone()
4866 },
4867 clones_dir: state.git_clones_dir.clone(),
4868 cocomo_mode: form
4869 .cocomo_mode
4870 .clone()
4871 .unwrap_or_else(|| "organic".to_string()),
4872 complexity_alert: form
4873 .complexity_alert
4874 .as_deref()
4875 .and_then(|s| s.parse::<u32>().ok())
4876 .unwrap_or(0),
4877 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4878 };
4879
4880 tokio::spawn(run_analysis_task(task));
4881
4882 let template = ScanWaitTemplate {
4883 version: env!("CARGO_PKG_VERSION"),
4884 wait_id_json,
4885 project_path: form.path.clone(),
4886 csp_nonce,
4887 };
4888 let html = template
4889 .render()
4890 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4891 let mut response = Html(html).into_response();
4892 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4893 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4894 response.headers_mut().insert(name, val);
4895 }
4896 }
4897 response
4898}
4899
4900struct AnalysisTask {
4901 sem_permit: tokio::sync::OwnedSemaphorePermit,
4902 state: AppState,
4903 wait_id: String,
4904 config: AppConfig,
4905 cancel: Arc<std::sync::atomic::AtomicBool>,
4906 phase: Arc<std::sync::Mutex<String>>,
4907 files_done: Arc<std::sync::atomic::AtomicUsize>,
4908 files_total: Arc<std::sync::atomic::AtomicUsize>,
4909 git_repo: Option<String>,
4910 git_ref: Option<String>,
4911 project_path: String,
4912 output_dir: Option<String>,
4913 clones_dir: PathBuf,
4914 cocomo_mode: String,
4915 complexity_alert: u32,
4916 exclude_duplicates: bool,
4917}
4918
4919#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4921 let _permit = task.sem_permit;
4922
4923 let cancel_sb = Arc::clone(&task.cancel);
4924 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4925 let clones_dir_sb = task.clones_dir;
4926 let upload_staging_root = task
4928 .config
4929 .discovery
4930 .root_paths
4931 .first()
4932 .filter(|p| is_upload_tmp_path(p))
4933 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4934 .map(PathBuf::from);
4935 let config_sb = task.config;
4936 let progress_sb = sloc_core::ProgressCounters {
4937 files_done: Arc::clone(&task.files_done),
4938 files_total: Arc::clone(&task.files_total),
4939 };
4940 if let Ok(mut p) = task.phase.lock() {
4941 *p = "Scanning files".to_string();
4942 }
4943 let analysis_result = tokio::task::spawn_blocking(move || {
4944 run_analysis_blocking(
4945 config_sb,
4946 git_repo_sb,
4947 git_ref_sb,
4948 clones_dir_sb,
4949 cancel_sb,
4950 Some(progress_sb),
4951 )
4952 })
4953 .await
4954 .map_err(|err| anyhow::anyhow!(err.to_string()))
4955 .and_then(|result| result);
4956
4957 if let Ok(mut p) = task.phase.lock() {
4958 *p = "Writing reports".to_string();
4959 }
4960
4961 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4963 let mut runs = task.state.async_runs.lock().await;
4964 if matches!(
4966 runs.get(&task.wait_id),
4967 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4968 ) {
4969 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4970 }
4971 drop(runs);
4972 return;
4973 }
4974
4975 let run = match analysis_result {
4976 Ok(v) => v,
4977 Err(err) => {
4978 if err.to_string().contains("analysis cancelled") {
4980 let mut runs = task.state.async_runs.lock().await;
4981 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4982 drop(runs);
4983 return;
4984 }
4985 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4986 let mut runs = task.state.async_runs.lock().await;
4987 runs.insert(
4988 task.wait_id.clone(),
4989 AsyncRunState::Failed {
4990 message: "Analysis failed. Check that the path exists and is readable."
4991 .to_string(),
4992 },
4993 );
4994 drop(runs);
4995 return;
4996 }
4997 };
4998
4999 let run_id = run.tool.run_id.clone();
5000 tracing::info!(event = "scan_complete", run_id = %run_id,
5001 path = %task.project_path, files = run.summary_totals.files_analyzed,
5002 "Analysis finished");
5003
5004 let prev_entry: Option<RegistryEntry> = {
5005 let reg = task.state.registry.lock().await;
5006 reg.entries_for_roots(&run.input_roots)
5007 .into_iter()
5008 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5009 .cloned()
5010 };
5011
5012 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5013 prev.json_path
5014 .as_ref()
5015 .and_then(|p| read_json(p).ok())
5016 .map(|prev_run| compute_delta(&prev_run, &run))
5017 });
5018 let prev_scan_count: usize = {
5019 let reg = task.state.registry.lock().await;
5020 reg.entries_for_roots(&run.input_roots)
5021 .iter()
5022 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5023 .count()
5024 };
5025
5026 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5029 .as_ref()
5030 .zip(prev_entry.as_ref())
5031 .map(|(cmp, prev)| ReportDeltaContext {
5032 delta_code_added: sum_added_code_lines(cmp),
5033 delta_code_removed: sum_removed_code_lines(cmp),
5034 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5035 delta_files_added: cmp.files_added,
5036 delta_files_removed: cmp.files_removed,
5037 delta_files_modified: cmp.files_modified,
5038 delta_files_unchanged: cmp.files_unchanged,
5039 prev_code_lines: prev.summary.code_lines,
5040 prev_scan_count: prev_scan_count + 1,
5041 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5042 prev_run_id: Some(prev.run_id.clone()),
5043 current_run_id: Some(run_id.clone()),
5044 });
5045 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5046 Ok(h) => h,
5047 Err(err) => {
5048 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5049 let mut runs = task.state.async_runs.lock().await;
5050 runs.insert(
5051 task.wait_id.clone(),
5052 AsyncRunState::Failed {
5053 message: "Failed to render HTML report.".to_string(),
5054 },
5055 );
5056 drop(runs);
5057 return;
5058 }
5059 };
5060
5061 let output_root = resolve_output_root(task.output_dir.as_deref());
5062 let project_label = derive_project_label(
5063 task.git_repo.as_deref(),
5064 task.git_ref.as_deref(),
5065 &task.project_path,
5066 );
5067 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5068 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5069
5070 let result_context = RunResultContext {
5071 prev_entry: prev_entry.clone(),
5072 prev_scan_count,
5073 project_path: task.project_path.clone(),
5074 cocomo_mode: task.cocomo_mode.clone(),
5075 complexity_alert: task.complexity_alert,
5076 exclude_duplicates: task.exclude_duplicates,
5077 };
5078
5079 let artifact_result = persist_run_artifacts(
5080 &run,
5081 &report_html,
5082 &run_dir,
5083 &run.effective_configuration.reporting.report_title,
5084 &file_stem,
5085 result_context,
5086 );
5087
5088 let (artifacts, pending_pdf) = match artifact_result {
5089 Ok(v) => v,
5090 Err(err) => {
5091 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5092 let mut runs = task.state.async_runs.lock().await;
5093 runs.insert(
5094 task.wait_id.clone(),
5095 AsyncRunState::Failed {
5096 message: "Failed to save report artifacts. Check available disk space."
5097 .to_string(),
5098 },
5099 );
5100 drop(runs);
5101 return;
5102 }
5103 };
5104
5105 {
5106 let mut map = task.state.artifacts.lock().await;
5107 map.insert(run_id.clone(), artifacts.clone());
5108 }
5109
5110 {
5111 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5112 let mut reg = task.state.registry.lock().await;
5113 reg.add_entry(entry);
5114 let _ = reg.save(&task.state.registry_path);
5115 }
5116
5117 if let Some(ref cfg_path) = artifacts.scan_config_path {
5118 save_scan_config_json(
5119 cfg_path,
5120 &run,
5121 &task.project_path,
5122 task.output_dir.as_deref(),
5123 &task.cocomo_mode,
5124 task.complexity_alert,
5125 task.exclude_duplicates,
5126 );
5127 }
5128
5129 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5130
5131 prom_runs_total().inc();
5132
5133 let mut runs = task.state.async_runs.lock().await;
5135 runs.insert(
5136 task.wait_id.clone(),
5137 AsyncRunState::Complete {
5138 run_id: run_id.clone(),
5139 },
5140 );
5141 drop(runs);
5142
5143 if let Some(staging) = upload_staging_root {
5146 let _ = tokio::fs::remove_dir_all(staging).await;
5147 }
5148
5149 let _ = scan_delta;
5150}
5151
5152fn save_scan_config_json(
5153 cfg_path: &std::path::Path,
5154 run: &sloc_core::AnalysisRun,
5155 project_path: &str,
5156 output_dir: Option<&str>,
5157 cocomo_mode: &str,
5158 complexity_alert: u32,
5159 exclude_duplicates: bool,
5160) {
5161 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5162 .ok()
5163 .and_then(|v| v.as_str().map(String::from))
5164 .unwrap_or_else(|| "code_only".to_string());
5165 let behavior_str =
5166 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5167 .ok()
5168 .and_then(|v| v.as_str().map(String::from))
5169 .unwrap_or_else(|| "skip".to_string());
5170 let continuation_policy_str = serde_json::to_value(
5171 run.effective_configuration
5172 .analysis
5173 .continuation_line_policy,
5174 )
5175 .ok()
5176 .and_then(|v| v.as_str().map(String::from))
5177 .unwrap_or_else(default_each_physical_line);
5178 let blank_policy_str = serde_json::to_value(
5179 run.effective_configuration
5180 .analysis
5181 .blank_in_block_comment_policy,
5182 )
5183 .ok()
5184 .and_then(|v| v.as_str().map(String::from))
5185 .unwrap_or_else(default_count_as_comment);
5186 let scan_cfg = ScanConfig {
5187 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5188 path: project_path.to_string(),
5189 include_globs: run
5190 .effective_configuration
5191 .discovery
5192 .include_globs
5193 .join("\n"),
5194 exclude_globs: run
5195 .effective_configuration
5196 .discovery
5197 .exclude_globs
5198 .join("\n"),
5199 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5200 mixed_line_policy: policy_str,
5201 python_docstrings_as_comments: run
5202 .effective_configuration
5203 .analysis
5204 .python_docstrings_as_comments,
5205 generated_file_detection: run
5206 .effective_configuration
5207 .analysis
5208 .generated_file_detection,
5209 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5210 vendor_directory_detection: run
5211 .effective_configuration
5212 .analysis
5213 .vendor_directory_detection,
5214 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5215 binary_file_behavior: behavior_str,
5216 output_dir: output_dir.unwrap_or("").to_string(),
5217 report_title: run.effective_configuration.reporting.report_title.clone(),
5218 continuation_line_policy: continuation_policy_str,
5219 blank_in_block_comment_policy: blank_policy_str,
5220 count_compiler_directives: run
5221 .effective_configuration
5222 .analysis
5223 .count_compiler_directives,
5224 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5225 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5226 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5227 style_lang_scope: run
5228 .effective_configuration
5229 .analysis
5230 .style_lang_scope
5231 .clone(),
5232 coverage_file: run
5233 .effective_configuration
5234 .analysis
5235 .coverage_file
5236 .as_ref()
5237 .map(|p| p.display().to_string())
5238 .unwrap_or_default(),
5239 cocomo_mode: cocomo_mode.to_string(),
5240 complexity_alert,
5241 exclude_duplicates,
5242 activity_window: run
5243 .effective_configuration
5244 .analysis
5245 .activity_window_days
5246 .unwrap_or(0),
5247 };
5248 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5249 let _ = std::fs::write(cfg_path, json);
5250 }
5251}
5252
5253#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5255 mut config: AppConfig,
5256 git_repo: Option<String>,
5257 git_ref: Option<String>,
5258 clones_dir: PathBuf,
5259 cancel: Arc<std::sync::atomic::AtomicBool>,
5260 progress: Option<sloc_core::ProgressCounters>,
5261) -> Result<sloc_core::AnalysisRun> {
5262 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5263 let dest = git_clone_dest(&repo, &clones_dir);
5264 sloc_git::clone_or_fetch(&repo, &dest)?;
5265 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5266 sloc_git::create_worktree(&dest, &refname, &wt)?;
5267 config.discovery.root_paths = vec![wt.clone()];
5268 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5269 let _ = sloc_git::destroy_worktree(&dest, &wt);
5270 let mut run = run?;
5271 if run.git_branch.is_none() {
5272 run.git_branch = Some(refname);
5273 }
5274 return Ok(run);
5275 }
5276 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5277}
5278
5279fn derive_project_label(
5280 git_repo: Option<&str>,
5281 git_ref: Option<&str>,
5282 fallback_path: &str,
5283) -> String {
5284 match (
5285 git_repo.filter(|s| !s.is_empty()),
5286 git_ref.filter(|s| !s.is_empty()),
5287 ) {
5288 (Some(repo), Some(refname)) => {
5289 let repo_name = repo
5290 .trim_end_matches('/')
5291 .trim_end_matches(".git")
5292 .rsplit('/')
5293 .next()
5294 .unwrap_or("repo");
5295 sanitize_project_label(&format!("{repo_name}_{refname}"))
5296 }
5297 _ => sanitize_project_label(fallback_path),
5298 }
5299}
5300
5301fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5302 let commit = commit_short.unwrap_or("").trim();
5303 if commit.is_empty() {
5304 project_label.to_string()
5305 } else {
5306 format!("{project_label}_{commit}")
5307 }
5308}
5309
5310#[derive(Serialize)]
5313#[serde(tag = "state", rename_all = "snake_case")]
5314enum AsyncRunStatusResponse {
5315 Running {
5316 elapsed_secs: u64,
5317 phase: String,
5318 files_done: u64,
5319 files_total: u64,
5320 },
5321 Complete {
5322 run_id: String,
5323 },
5324 Failed {
5325 message: String,
5326 },
5327 Cancelled,
5328}
5329
5330async fn async_run_status_handler(
5331 State(state): State<AppState>,
5332 AxumPath(wait_id): AxumPath<String>,
5333) -> Response {
5334 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5336 return error::bad_request("invalid wait_id");
5337 }
5338 let run_state = {
5339 let runs = state.async_runs.lock().await;
5340 runs.get(&wait_id).cloned()
5341 };
5342 match run_state {
5343 None => error::not_found("run not found"),
5344 Some(AsyncRunState::Running {
5345 started_at,
5346 phase,
5347 files_done,
5348 files_total,
5349 ..
5350 }) => {
5351 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5353 let mut runs = state.async_runs.lock().await;
5354 runs.insert(
5355 wait_id,
5356 AsyncRunState::Failed {
5357 message: "Analysis timed out after 2 hours.".to_string(),
5358 },
5359 );
5360 drop(runs);
5361 return Json(AsyncRunStatusResponse::Failed {
5362 message: "Analysis timed out after 2 hours.".to_string(),
5363 })
5364 .into_response();
5365 }
5366 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5367 Json(AsyncRunStatusResponse::Running {
5368 elapsed_secs: started_at.elapsed().as_secs(),
5369 phase: phase_str,
5370 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5371 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5372 })
5373 .into_response()
5374 }
5375 Some(AsyncRunState::Complete { run_id }) => {
5376 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5377 }
5378 Some(AsyncRunState::Failed { message }) => {
5379 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5380 }
5381 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5382 }
5383}
5384
5385async fn cancel_run_handler(
5386 State(state): State<AppState>,
5387 AxumPath(wait_id): AxumPath<String>,
5388) -> Response {
5389 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5390 return error::bad_request("invalid wait_id");
5391 }
5392 let mut runs = state.async_runs.lock().await;
5393 let resp = match runs.get(&wait_id) {
5394 Some(AsyncRunState::Running { cancel_token, .. }) => {
5395 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5396 runs.insert(wait_id, AsyncRunState::Cancelled);
5397 StatusCode::OK.into_response()
5398 }
5399 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5400 _ => error::not_found("run not found"),
5401 };
5402 drop(runs);
5403 resp
5404}
5405
5406async fn async_run_result_handler(
5407 State(state): State<AppState>,
5408 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5409 AxumPath(run_id): AxumPath<String>,
5410) -> Response {
5411 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5412 return StatusCode::BAD_REQUEST.into_response();
5413 }
5414
5415 let artifacts = {
5416 let map = state.artifacts.lock().await;
5417 map.get(&run_id).cloned()
5418 };
5419 let artifacts = if let Some(a) = artifacts {
5420 a
5421 } else {
5422 let reg = state.registry.lock().await;
5423 if let Some(entry) = reg.find_by_run_id(&run_id) {
5424 recover_artifacts_from_registry(entry)
5425 } else {
5426 let html = ErrorTemplate {
5427 message: format!(
5428 "Report not found. Run ID {} is not in the scan history.",
5429 &run_id[..run_id.len().min(8)]
5430 ),
5431 last_report_url: Some("/view-reports".to_string()),
5432 last_report_label: Some("View Reports".to_string()),
5433 run_id: Some(run_id.clone()),
5434 error_code: Some(404),
5435 csp_nonce: csp_nonce.clone(),
5436 version: env!("CARGO_PKG_VERSION"),
5437 }
5438 .render()
5439 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5440 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5441 }
5442 };
5443
5444 let json_path = if let Some(p) = &artifacts.json_path {
5445 p.clone()
5446 } else {
5447 let html = ErrorTemplate {
5448 message: "JSON result was not saved for this run.".to_string(),
5449 last_report_url: Some("/view-reports".to_string()),
5450 last_report_label: Some("View Reports".to_string()),
5451 run_id: Some(run_id.clone()),
5452 error_code: Some(404),
5453 csp_nonce: csp_nonce.clone(),
5454 version: env!("CARGO_PKG_VERSION"),
5455 }
5456 .render()
5457 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5458 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5459 };
5460
5461 let Ok(run) = read_json(&json_path) else {
5462 let folder_hint = output_folder_hint(&json_path);
5463 let redirect_url = format!("/runs/result/{run_id}");
5464 return missing_scan_relocate_response(
5465 &format!(
5466 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5467 deleted. Browse to the folder containing your scan output to reconnect it.",
5468 json_path.display()
5469 ),
5470 &run_id,
5471 &folder_hint,
5472 &redirect_url,
5473 state.server_mode,
5474 &csp_nonce,
5475 );
5476 };
5477
5478 let confluence_configured = {
5479 let store = state.confluence.lock().await;
5480 store.is_configured()
5481 };
5482
5483 render_result_page(
5484 &run,
5485 &artifacts,
5486 &run_id,
5487 &csp_nonce,
5488 confluence_configured,
5489 state.server_mode,
5490 )
5491}
5492
5493fn json_escape(s: &str) -> String {
5495 s.replace('\\', "\\\\").replace('"', "\\\"")
5496}
5497
5498struct LangTotals {
5500 physical_lines: u64,
5501 code_lines: u64,
5502 comment_lines: u64,
5503 blank_lines: u64,
5504 mixed_lines: u64,
5505 functions: u64,
5506 classes: u64,
5507 variables: u64,
5508 imports: u64,
5509}
5510
5511fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5512 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5513 run.totals_by_language.iter().map(f).sum()
5514 };
5515 LangTotals {
5516 physical_lines: s(|r| r.total_physical_lines),
5517 code_lines: s(|r| r.code_lines),
5518 comment_lines: s(|r| r.comment_lines),
5519 blank_lines: s(|r| r.blank_lines),
5520 mixed_lines: s(|r| r.mixed_lines_separate),
5521 functions: s(|r| r.functions),
5522 classes: s(|r| r.classes),
5523 variables: s(|r| r.variables),
5524 imports: s(|r| r.imports),
5525 }
5526}
5527
5528struct DeltaFields {
5530 prev_fa_str: String,
5531 prev_fs_str: String,
5532 prev_pl_str: String,
5533 prev_cl_str: String,
5534 prev_cml_str: String,
5535 prev_bl_str: String,
5536 delta_fa_str: String,
5537 delta_fa_class: String,
5538 delta_fs_str: String,
5539 delta_fs_class: String,
5540 delta_pl_str: String,
5541 delta_pl_class: String,
5542 delta_cl_str: String,
5543 delta_cl_class: String,
5544 delta_cml_str: String,
5545 delta_cml_class: String,
5546 delta_bl_str: String,
5547 delta_bl_class: String,
5548 delta_lines_added: Option<i64>,
5549 delta_lines_removed: Option<i64>,
5550 delta_lines_net_str: String,
5551 delta_lines_net_class: String,
5552}
5553
5554#[allow(
5559 clippy::similar_names,
5560 reason = "locals mirror template-bound struct fields"
5561)]
5562fn compute_delta_fields(
5563 prev_entry: Option<&RegistryEntry>,
5564 totals: &LangTotals,
5565 files_analyzed: u64,
5566 files_skipped: u64,
5567 scan_delta: Option<&sloc_core::ScanComparison>,
5568) -> DeltaFields {
5569 let prev_sum = prev_entry.map(|e| &e.summary);
5570 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5571
5572 let (delta_fa_str, delta_fa_class) =
5573 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5574 let (delta_fs_str, delta_fs_class) =
5575 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5576 let (delta_pl_str, delta_pl_class) = summary_delta(
5577 totals.physical_lines,
5578 prev_sum.map(|s| s.total_physical_lines),
5579 );
5580 let (delta_cl_str, delta_cl_class) =
5581 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5582 let (delta_cml_str, delta_cml_class) =
5583 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5584 let (delta_bl_str, delta_bl_class) =
5585 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5586
5587 let delta_lines_added = scan_delta.map(sum_added_code_lines);
5588 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5589 let (delta_lines_net_str, delta_lines_net_class) =
5590 match (delta_lines_added, delta_lines_removed) {
5591 (Some(a), Some(r)) => {
5592 let net = a - r;
5593 (fmt_delta(net), delta_class(net).to_string())
5594 }
5595 _ => ("\u{2014}".to_string(), "na".to_string()),
5596 };
5597
5598 DeltaFields {
5599 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5600 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5601 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5602 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5603 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5604 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5605 delta_fa_str,
5606 delta_fa_class: delta_fa_class.to_string(),
5607 delta_fs_str,
5608 delta_fs_class: delta_fs_class.to_string(),
5609 delta_pl_str,
5610 delta_pl_class: delta_pl_class.to_string(),
5611 delta_cl_str,
5612 delta_cl_class: delta_cl_class.to_string(),
5613 delta_cml_str,
5614 delta_cml_class: delta_cml_class.to_string(),
5615 delta_bl_str,
5616 delta_bl_class: delta_bl_class.to_string(),
5617 delta_lines_added,
5618 delta_lines_removed,
5619 delta_lines_net_str,
5620 delta_lines_net_class,
5621 }
5622}
5623
5624fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5626 scan_delta
5627 .file_deltas
5628 .iter()
5629 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5630 .map(|f| {
5631 #[allow(clippy::cast_sign_loss)]
5632 let n = f.current_code as u64;
5633 n
5634 })
5635 .sum()
5636}
5637
5638fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5639 run.git_remote_url
5640 .as_deref()
5641 .zip(run.git_commit_long.as_deref())
5642 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5643}
5644
5645fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5646 run.git_remote_url
5647 .as_deref()
5648 .zip(run.git_branch.as_deref())
5649 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5650}
5651
5652fn scan_performed_by(run: &AnalysisRun) -> String {
5653 run.environment.ci_name.clone().unwrap_or_else(|| {
5654 format!(
5655 "{} / {}",
5656 run.environment.initiator_username, run.environment.initiator_hostname
5657 )
5658 })
5659}
5660
5661fn build_lang_chart_json(run: &AnalysisRun) -> String {
5663 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5664 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5665 let entries: Vec<String> = langs
5666 .into_iter()
5667 .take(12)
5668 .map(|l| {
5669 let name = json_escape(l.language.display_name());
5670 format!(
5671 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5672 name,
5673 l.code_lines,
5674 l.comment_lines,
5675 l.blank_lines,
5676 l.total_physical_lines,
5677 l.functions,
5678 l.classes,
5679 l.variables,
5680 l.imports,
5681 l.files,
5682 )
5683 })
5684 .collect();
5685 format!("[{}]", entries.join(","))
5686}
5687
5688fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5690 let entries: Vec<String> = run
5691 .totals_by_language
5692 .iter()
5693 .map(|l| {
5694 let name = json_escape(l.language.display_name());
5695 format!(
5696 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5697 name, l.files, l.code_lines, l.total_physical_lines,
5698 )
5699 })
5700 .collect();
5701 format!("[{}]", entries.join(","))
5702}
5703
5704fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5706 let entries: Vec<String> = run
5707 .totals_by_language
5708 .iter()
5709 .filter(|l| {
5710 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5711 })
5712 .map(|l| {
5713 let name = json_escape(l.language.display_name());
5714 format!(
5715 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5716 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5717 )
5718 })
5719 .collect();
5720 format!("[{}]", entries.join(","))
5721}
5722
5723fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5725 let entries: Vec<String> = run
5726 .submodule_summaries
5727 .iter()
5728 .map(|s| {
5729 let name = json_escape(&s.name);
5730 format!(
5731 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5732 name,
5733 s.code_lines,
5734 s.comment_lines,
5735 s.blank_lines,
5736 s.total_physical_lines,
5737 s.files_analyzed,
5738 )
5739 })
5740 .collect();
5741 format!("[{}]", entries.join(","))
5742}
5743
5744#[allow(clippy::cast_precision_loss)]
5746fn cov_pct_str(hit: u64, found: u64) -> String {
5747 if found > 0 {
5748 format!("{:.1}", hit as f64 / found as f64 * 100.0)
5749 } else {
5750 String::new()
5751 }
5752}
5753
5754fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5756 if found > 0 {
5757 format!("{hit} / {found}")
5758 } else {
5759 String::new()
5760 }
5761}
5762
5763const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5764 use sloc_core::CocomoMode;
5765 match mode {
5766 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5767 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5768 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5769 }
5770}
5771
5772const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5773 use sloc_core::CocomoMode;
5774 match mode {
5775 CocomoMode::Organic => "Organic",
5776 CocomoMode::SemiDetached => "Semi-detached",
5777 CocomoMode::Embedded => "Embedded",
5778 }
5779}
5780
5781const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5782 use sloc_core::CocomoMode;
5783 match mode {
5784 CocomoMode::Organic => {
5785 "Organic: A small team working on a well-understood project in a familiar \
5786 environment with minimal external constraints. Suited for internal tools, \
5787 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5788 }
5789 CocomoMode::SemiDetached => {
5790 "Semi-detached: A mixed team with varying experience tackling a project with \
5791 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5792 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5793 }
5794 CocomoMode::Embedded => {
5795 "Embedded: Tight hardware, software, or operational constraints requiring \
5796 significant innovation and deep integration work. Typical for real-time control \
5797 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5798 }
5799 }
5800}
5801
5802struct CocomoFields {
5804 has_cocomo: bool,
5805 effort_str: String,
5806 duration_str: String,
5807 staff_str: String,
5808 ksloc_str: String,
5809 mode_label: String,
5810 mode_tooltip: String,
5811}
5812
5813#[allow(clippy::cast_precision_loss)]
5814fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5815 use sloc_core::CocomoMode;
5816 let mode = match mode_str {
5817 "semi_detached" => CocomoMode::SemiDetached,
5818 "embedded" => CocomoMode::Embedded,
5819 _ => CocomoMode::Organic,
5820 };
5821 let (a, b, c, d) = cocomo_coefficients(mode);
5822 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5823 let effort = a * ksloc.powf(b);
5824 let duration = c * effort.powf(d);
5825 let staff = if duration > 0.0 {
5826 effort / duration
5827 } else {
5828 0.0
5829 };
5830 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5831 let mode_label = cocomo_mode_label(mode).to_string();
5832 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5833 if run.summary_totals.code_lines > 0 {
5834 CocomoFields {
5835 has_cocomo: true,
5836 effort_str: round2(effort),
5837 duration_str: round2(duration),
5838 staff_str: round2(staff),
5839 ksloc_str: round2(ksloc),
5840 mode_label,
5841 mode_tooltip,
5842 }
5843 } else {
5844 CocomoFields {
5845 has_cocomo: false,
5846 effort_str: String::new(),
5847 duration_str: String::new(),
5848 staff_str: String::new(),
5849 ksloc_str: String::new(),
5850 mode_label,
5851 mode_tooltip,
5852 }
5853 }
5854}
5855
5856#[allow(clippy::too_many_lines)]
5857#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
5860 run: &AnalysisRun,
5861 artifacts: &RunArtifacts,
5862 run_id: &str,
5863 csp_nonce: &str,
5864 confluence_configured: bool,
5865 server_mode: bool,
5866) -> Response {
5867 let ctx = &artifacts.result_context;
5868 let prev_entry = &ctx.prev_entry;
5869 let prev_scan_count = ctx.prev_scan_count;
5870 let project_path_owned = if ctx.project_path.is_empty() {
5874 run.input_roots.join(", ")
5875 } else {
5876 ctx.project_path.clone()
5877 };
5878 let project_path = &project_path_owned;
5879
5880 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5881 prev.json_path
5882 .as_ref()
5883 .and_then(|p| read_json(p).ok())
5884 .map(|prev_run| compute_delta(&prev_run, run))
5885 });
5886
5887 let files_analyzed = run.per_file_records.len() as u64;
5888 let files_skipped = run.skipped_file_records.len() as u64;
5889 let totals = sum_lang_totals(run);
5890
5891 let DeltaFields {
5892 prev_fa_str,
5893 prev_fs_str,
5894 prev_pl_str,
5895 prev_cl_str,
5896 prev_cml_str,
5897 prev_bl_str,
5898 delta_fa_str,
5899 delta_fa_class,
5900 delta_fs_str,
5901 delta_fs_class,
5902 delta_pl_str,
5903 delta_pl_class,
5904 delta_cl_str,
5905 delta_cl_class,
5906 delta_cml_str,
5907 delta_cml_class,
5908 delta_bl_str,
5909 delta_bl_class,
5910 delta_lines_added,
5911 delta_lines_removed,
5912 delta_lines_net_str,
5913 delta_lines_net_class,
5914 } = compute_delta_fields(
5915 prev_entry.as_ref(),
5916 &totals,
5917 files_analyzed,
5918 files_skipped,
5919 scan_delta.as_ref(),
5920 );
5921
5922 let run_dir = artifacts.output_dir.clone();
5923 let git_branch = run.git_branch.clone();
5924 let git_commit = run.git_commit_short.clone();
5925 let git_commit_long = run.git_commit_long.clone();
5926 let git_author = run.git_commit_author.clone();
5927 let git_commit_url = git_commit_url_for(run);
5928 let git_branch_url = git_branch_url_for(run);
5929 let scan_performed_by = scan_performed_by(run);
5930 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5931 let os_display = format!(
5932 "{} / {}",
5933 run.environment.operating_system, run.environment.architecture
5934 );
5935 let test_count = run.summary_totals.test_count;
5936
5937 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5939 let lsloc = run.summary_totals.lsloc;
5940 let uloc = run.uloc;
5941 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5942 let duplicate_group_count = run.duplicate_groups.len();
5943
5944 let ctx = &artifacts.result_context;
5946 let CocomoFields {
5947 has_cocomo,
5948 effort_str: cocomo_effort_str,
5949 duration_str: cocomo_duration_str,
5950 staff_str: cocomo_staff_str,
5951 ksloc_str: cocomo_ksloc_str,
5952 mode_label: cocomo_mode_label,
5953 mode_tooltip: cocomo_mode_tooltip,
5954 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
5955 let complexity_alert = ctx.complexity_alert;
5956
5957 let template = ResultTemplate {
5958 version: env!("CARGO_PKG_VERSION"),
5959 report_title: run.effective_configuration.reporting.report_title.clone(),
5960 project_path: project_path.clone(),
5961 output_dir: display_path(&artifacts.output_dir),
5962 run_id: run_id.to_owned(),
5963 run_id_short: run_id
5964 .split('-')
5965 .next_back()
5966 .unwrap_or(run_id)
5967 .chars()
5968 .take(7)
5969 .collect(),
5970 files_analyzed,
5971 files_skipped,
5972 physical_lines: totals.physical_lines,
5973 code_lines: totals.code_lines,
5974 comment_lines: totals.comment_lines,
5975 blank_lines: totals.blank_lines,
5976 mixed_lines: totals.mixed_lines,
5977 functions: totals.functions,
5978 classes: totals.classes,
5979 variables: totals.variables,
5980 imports: totals.imports,
5981 html_url: artifacts
5982 .html_path
5983 .as_ref()
5984 .map(|_| format!("/runs/html/{run_id}")),
5985 pdf_url: artifacts
5986 .pdf_path
5987 .as_ref()
5988 .map(|_| format!("/runs/pdf/{run_id}")),
5989 json_url: artifacts
5990 .json_path
5991 .as_ref()
5992 .map(|_| format!("/runs/json/{run_id}")),
5993 html_download_url: artifacts
5994 .html_path
5995 .as_ref()
5996 .map(|_| format!("/runs/html/{run_id}?download=1")),
5997 pdf_download_url: artifacts
5998 .pdf_path
5999 .as_ref()
6000 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6001 json_download_url: artifacts
6002 .json_path
6003 .as_ref()
6004 .map(|_| format!("/runs/json/{run_id}?download=1")),
6005 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6006 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6007 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6008 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6009 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6010 prev_fa_str,
6011 prev_fs_str,
6012 prev_pl_str,
6013 prev_cl_str,
6014 prev_cml_str,
6015 prev_bl_str,
6016 delta_fa_str,
6017 delta_fa_class,
6018 delta_fs_str,
6019 delta_fs_class,
6020 delta_pl_str,
6021 delta_pl_class,
6022 delta_cl_str,
6023 delta_cl_class,
6024 delta_cml_str,
6025 delta_cml_class,
6026 delta_bl_str,
6027 delta_bl_class,
6028 delta_lines_added,
6029 delta_lines_removed,
6030 delta_lines_net_str,
6031 delta_lines_net_class,
6032 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6033 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6034 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6035 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6036 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6037 git_branch,
6038 git_branch_url,
6039 git_commit,
6040 git_commit_long,
6041 git_author,
6042 git_commit_url,
6043 scan_performed_by,
6044 scan_time_display,
6045 os_display,
6046 test_count,
6047 test_assertion_count: run.summary_totals.test_assertion_count,
6048 current_scan_number: prev_scan_count + 1,
6049 prev_scan_count,
6050 submodule_rows: run
6051 .submodule_summaries
6052 .iter()
6053 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6054 .collect(),
6055 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6056 scan_config_url: format!("/runs/scan-config/{run_id}"),
6057 lang_chart_json: build_lang_chart_json(run),
6058 scatter_chart_json: build_scatter_chart_json(run),
6059 semantic_chart_json: build_semantic_chart_json(run),
6060 submodule_chart_json: build_submodule_chart_json(run),
6061 has_submodule_data: !run.submodule_summaries.is_empty(),
6062 has_semantic_data: run
6063 .totals_by_language
6064 .iter()
6065 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6066 csp_nonce: csp_nonce.to_owned(),
6067 confluence_configured,
6068 server_mode,
6069 report_header_footer: run
6070 .effective_configuration
6071 .reporting
6072 .report_header_footer
6073 .clone(),
6074 is_offline: false,
6075 cyclomatic_complexity,
6076 lsloc,
6077 uloc,
6078 dryness_pct_str,
6079 duplicate_group_count,
6080 has_cocomo,
6081 cocomo_effort_str,
6082 cocomo_duration_str,
6083 cocomo_staff_str,
6084 cocomo_ksloc_str,
6085 cocomo_mode_label,
6086 cocomo_mode_tooltip,
6087 complexity_alert,
6088 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6089 cov_line_pct: cov_pct_str(
6090 run.summary_totals.coverage_lines_hit,
6091 run.summary_totals.coverage_lines_found,
6092 ),
6093 cov_fn_pct: cov_pct_str(
6094 run.summary_totals.coverage_functions_hit,
6095 run.summary_totals.coverage_functions_found,
6096 ),
6097 cov_branch_pct: cov_pct_str(
6098 run.summary_totals.coverage_branches_hit,
6099 run.summary_totals.coverage_branches_found,
6100 ),
6101 cov_lines_summary: cov_lines_summary_str(
6102 run.summary_totals.coverage_lines_hit,
6103 run.summary_totals.coverage_lines_found,
6104 ),
6105 };
6106
6107 Html(
6108 template
6109 .render()
6110 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6111 )
6112 .into_response()
6113}
6114
6115fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6116 let slug: String = report_title
6117 .chars()
6118 .map(|c| {
6119 if c.is_alphanumeric() || c == '-' {
6120 c.to_ascii_lowercase()
6121 } else {
6122 '_'
6123 }
6124 })
6125 .collect::<String>()
6126 .split('_')
6127 .filter(|s| !s.is_empty())
6128 .collect::<Vec<_>>()
6129 .join("_");
6130
6131 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6132
6133 if slug.is_empty() {
6134 format!("report_{short_id}.pdf")
6135 } else {
6136 format!("{slug}_{short_id}.pdf")
6137 }
6138}
6139
6140#[derive(Serialize)]
6141struct PdfStatusResponse {
6142 ready: bool,
6143}
6144
6145async fn pdf_status_handler(
6148 State(state): State<AppState>,
6149 AxumPath(run_id): AxumPath<String>,
6150) -> Response {
6151 let pdf_path = {
6152 let registry = state.artifacts.lock().await;
6153 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6154 };
6155 let pdf_path = if pdf_path.is_some() {
6156 pdf_path
6157 } else {
6158 let reg = state.registry.lock().await;
6159 reg.find_by_run_id(&run_id)
6160 .map(recover_artifacts_from_registry)
6161 .and_then(|a| a.pdf_path)
6162 };
6163 let ready = pdf_path.is_some_and(|p| p.exists());
6164 Json(PdfStatusResponse { ready }).into_response()
6165}
6166
6167async fn download_bundle_handler(
6173 State(state): State<AppState>,
6174 AxumPath(run_id): AxumPath<String>,
6175) -> Response {
6176 let output_dir = {
6178 let cache = state.artifacts.lock().await;
6179 cache.get(&run_id).map(|a| a.output_dir.clone())
6180 };
6181 let output_dir = if let Some(d) = output_dir {
6182 d
6183 } else {
6184 let reg = state.registry.lock().await;
6185 match reg.find_by_run_id(&run_id) {
6186 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6187 None => {
6188 return (
6189 StatusCode::NOT_FOUND,
6190 Json(serde_json::json!({"error": "Run not found"})),
6191 )
6192 .into_response();
6193 }
6194 }
6195 };
6196
6197 if !output_dir.exists() {
6198 return (
6199 StatusCode::NOT_FOUND,
6200 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6201 )
6202 .into_response();
6203 }
6204
6205 let run_id_clone = run_id.clone();
6207 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6208 use flate2::{write::GzEncoder, Compression};
6209 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6210 {
6211 let mut tar = tar::Builder::new(&mut enc);
6212 tar.follow_symlinks(false);
6213 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6216 for entry in entries.filter_map(Result::ok) {
6217 let p = entry.path();
6218 if p.is_file() {
6219 let name = p.file_name().unwrap_or_default().to_string_lossy();
6220 let archive_path = format!("{run_id_clone}/{name}");
6221 tar.append_path_with_name(&p, &archive_path)?;
6222 }
6223 }
6224 }
6225 tar.finish()?;
6226 }
6227 Ok(enc.finish()?)
6228 })
6229 .await;
6230
6231 match archive_result {
6232 Ok(Ok(bytes)) => {
6233 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6234 axum::response::Response::builder()
6235 .status(StatusCode::OK)
6236 .header("Content-Type", "application/gzip")
6237 .header(
6238 "Content-Disposition",
6239 format!("attachment; filename=\"{filename}\""),
6240 )
6241 .header("Content-Length", bytes.len().to_string())
6242 .body(axum::body::Body::from(bytes))
6243 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6244 }
6245 Ok(Err(e)) => (
6246 StatusCode::INTERNAL_SERVER_ERROR,
6247 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6248 )
6249 .into_response(),
6250 Err(e) => (
6251 StatusCode::INTERNAL_SERVER_ERROR,
6252 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6253 )
6254 .into_response(),
6255 }
6256}
6257
6258async fn delete_run_handler(
6263 State(state): State<AppState>,
6264 AxumPath(run_id): AxumPath<String>,
6265) -> Response {
6266 let output_dir = {
6268 let mut cache = state.artifacts.lock().await;
6269 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6270 cache.remove(&run_id);
6271 dir
6272 };
6273 let output_dir = if let Some(d) = output_dir {
6274 d
6275 } else {
6276 let reg = state.registry.lock().await;
6277 reg.find_by_run_id(&run_id)
6278 .map(|e| recover_artifacts_from_registry(e).output_dir)
6279 .unwrap_or_default()
6280 };
6281
6282 {
6284 let mut reg = state.registry.lock().await;
6285 reg.entries.retain(|e| e.run_id != run_id);
6286 let _ = reg.save(&state.registry_path);
6287 }
6288
6289 if output_dir.exists() {
6292 match tokio::fs::remove_dir_all(&output_dir).await {
6293 Ok(()) => {}
6294 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6295 Err(e) => {
6296 return (
6297 StatusCode::INTERNAL_SERVER_ERROR,
6298 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6299 )
6300 .into_response();
6301 }
6302 }
6303 }
6304
6305 StatusCode::NO_CONTENT.into_response()
6306}
6307
6308async fn cleanup_runs_handler(
6313 State(state): State<AppState>,
6314 Json(body): Json<serde_json::Value>,
6315) -> Response {
6316 let days = body
6317 .get("older_than_days")
6318 .and_then(serde_json::Value::as_u64)
6319 .unwrap_or(30)
6320 .max(1);
6321
6322 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6323
6324 let expired: Vec<(String, PathBuf)> = {
6326 let reg = state.registry.lock().await;
6327 reg.entries
6328 .iter()
6329 .filter(|e| e.timestamp_utc < cutoff)
6330 .map(|e| {
6331 let arts = recover_artifacts_from_registry(e);
6332 (e.run_id.clone(), arts.output_dir)
6333 })
6334 .collect()
6335 };
6336
6337 let mut deleted = 0usize;
6338 for (run_id, output_dir) in &expired {
6339 state.artifacts.lock().await.remove(run_id);
6341 if output_dir.exists() {
6343 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6344 eprintln!(
6345 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6346 output_dir.display()
6347 );
6348 continue;
6349 }
6350 }
6351 deleted += 1;
6352 }
6353
6354 let expired_ids: std::collections::HashSet<&str> =
6356 expired.iter().map(|(id, _)| id.as_str()).collect();
6357 {
6358 let mut reg = state.registry.lock().await;
6359 reg.entries
6360 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6361 let _ = reg.save(&state.registry_path);
6362 }
6363
6364 Json(serde_json::json!({ "deleted": deleted })).into_response()
6365}
6366
6367fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6370 tokio::spawn(async move {
6371 loop {
6372 let interval_secs = {
6373 let store = state.cleanup_policy.lock().await;
6374 match &store.policy {
6375 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6376 _ => break,
6377 }
6378 };
6379 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6380 let n = run_auto_cleanup(&state).await;
6381 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6382 }
6383 })
6384}
6385
6386fn collect_runs_to_delete(
6387 reg: &ScanRegistry,
6388 max_age_days: Option<u32>,
6389 max_run_count: Option<u32>,
6390) -> std::collections::HashSet<String> {
6391 let mut to_delete = std::collections::HashSet::new();
6392 if let Some(days) = max_age_days {
6393 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6394 for e in ®.entries {
6395 if e.timestamp_utc < cutoff {
6396 to_delete.insert(e.run_id.clone());
6397 }
6398 }
6399 }
6400 if let Some(max_count) = max_run_count {
6401 for e in reg.entries.iter().skip(max_count as usize) {
6403 to_delete.insert(e.run_id.clone());
6404 }
6405 }
6406 to_delete
6407}
6408
6409async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6410 let output_dir = {
6411 let mut cache = state.artifacts.lock().await;
6412 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6413 cache.remove(run_id);
6414 d
6415 };
6416 let output_dir = if let Some(d) = output_dir {
6417 d
6418 } else {
6419 let reg = state.registry.lock().await;
6420 reg.find_by_run_id(run_id)
6421 .map(|e| recover_artifacts_from_registry(e).output_dir)
6422 .unwrap_or_default()
6423 };
6424 if output_dir.exists() {
6425 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6426 }
6427}
6428
6429async fn run_auto_cleanup(state: &AppState) -> u32 {
6433 let (max_age_days, max_run_count) = {
6434 let store = state.cleanup_policy.lock().await;
6435 match &store.policy {
6436 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6437 _ => return 0,
6438 }
6439 };
6440
6441 let to_delete = {
6442 let reg = state.registry.lock().await;
6443 collect_runs_to_delete(®, max_age_days, max_run_count)
6444 };
6445
6446 for run_id in &to_delete {
6447 delete_run_artifacts(state, run_id).await;
6448 }
6449
6450 if !to_delete.is_empty() {
6452 let mut reg = state.registry.lock().await;
6453 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6454 let _ = reg.save(&state.registry_path);
6455 }
6456
6457 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6458 {
6459 let mut store = state.cleanup_policy.lock().await;
6460 store.last_run_at = Some(chrono::Utc::now());
6461 store.last_run_deleted = Some(deleted);
6462 let _ = store.save(&state.cleanup_policy_path);
6463 }
6464 deleted
6465}
6466
6467async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6471 let store = state.cleanup_policy.lock().await;
6472 Json(serde_json::json!({
6473 "policy": store.policy,
6474 "last_run_at": store.last_run_at,
6475 "last_run_deleted": store.last_run_deleted,
6476 }))
6477 .into_response()
6478}
6479
6480async fn api_save_cleanup_policy(
6482 State(state): State<AppState>,
6483 Json(body): Json<CleanupPolicy>,
6484) -> Response {
6485 {
6487 let mut handle = state.cleanup_task_handle.lock().await;
6488 if let Some(h) = handle.take() {
6489 h.abort();
6490 }
6491 }
6492 {
6493 let mut store = state.cleanup_policy.lock().await;
6494 store.policy = Some(body.clone());
6495 if let Err(e) = store.save(&state.cleanup_policy_path) {
6496 return (
6497 StatusCode::INTERNAL_SERVER_ERROR,
6498 Json(serde_json::json!({"error": e.to_string()})),
6499 )
6500 .into_response();
6501 }
6502 }
6503 if body.enabled {
6504 let handle = spawn_cleanup_policy_task(state.clone());
6505 *state.cleanup_task_handle.lock().await = Some(handle);
6506 }
6507 StatusCode::NO_CONTENT.into_response()
6508}
6509
6510async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6512 let deleted = run_auto_cleanup(&state).await;
6513 Json(serde_json::json!({ "deleted": deleted })).into_response()
6514}
6515
6516async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6518 {
6519 let mut handle = state.cleanup_task_handle.lock().await;
6520 if let Some(h) = handle.take() {
6521 h.abort();
6522 }
6523 }
6524 {
6525 let mut store = state.cleanup_policy.lock().await;
6526 store.policy = None;
6527 let _ = store.save(&state.cleanup_policy_path);
6528 }
6529 StatusCode::NO_CONTENT.into_response()
6530}
6531
6532fn swap_inline_chart_js_for_static(html: String) -> String {
6538 let Some(head_end) = html.find("</head>") else {
6539 return html;
6540 };
6541 let Some(script_start) = html[..head_end].rfind("<script") else {
6542 return html;
6543 };
6544 let Some(close_offset) = html[script_start..].find("</script>") else {
6545 return html;
6546 };
6547 let block_end = script_start + close_offset + "</script>".len();
6548 format!(
6549 "{}<script src=\"/static/chart-report.js\"></script>{}",
6550 &html[..script_start],
6551 &html[block_end..]
6552 )
6553}
6554
6555fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6557 let Some(start) = html.find("nonce=\"") else {
6559 return html
6563 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6564 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6565 };
6566 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
6568 return html.to_owned();
6569 };
6570 let old_nonce = &html[value_start..value_start + end_offset];
6571 html.replace(
6572 &format!("nonce=\"{old_nonce}\""),
6573 &format!("nonce=\"{new_nonce}\""),
6574 )
6575}
6576
6577fn serve_html_artifact(
6578 path: &Path,
6579 wants_download: bool,
6580 csp_nonce: &str,
6581 run_id: &str,
6582 server_mode: bool,
6583) -> Response {
6584 match fs::read_to_string(path) {
6585 Ok(raw) => {
6586 let content = patch_html_nonce(&raw, csp_nonce);
6588 if wants_download {
6589 (
6591 [
6592 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6593 (
6594 header::CONTENT_DISPOSITION,
6595 "attachment; filename=report.html",
6596 ),
6597 ],
6598 content,
6599 )
6600 .into_response()
6601 } else {
6602 Html(swap_inline_chart_js_for_static(content)).into_response()
6605 }
6606 }
6607 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6608 let filename = path.file_name().map_or_else(
6609 || "report.html".to_string(),
6610 |n| n.to_string_lossy().into_owned(),
6611 );
6612 let html = LocateFileTemplate {
6613 run_id: run_id.to_owned(),
6614 artifact_type: "html".to_string(),
6615 expected_filename: filename,
6616 server_mode,
6617 csp_nonce: csp_nonce.to_owned(),
6618 version: env!("CARGO_PKG_VERSION"),
6619 }
6620 .render()
6621 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6622 (StatusCode::NOT_FOUND, Html(html)).into_response()
6623 }
6624 Err(err) => {
6625 let filename = path.file_name().map_or_else(
6626 || "report.html".to_string(),
6627 |n| n.to_string_lossy().into_owned(),
6628 );
6629 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6630 let html = ErrorTemplate {
6631 message: msg,
6632 last_report_url: Some("/view-reports".to_string()),
6633 last_report_label: Some("View Reports".to_string()),
6634 run_id: None,
6635 error_code: Some(404),
6636 csp_nonce: csp_nonce.to_owned(),
6637 version: env!("CARGO_PKG_VERSION"),
6638 }
6639 .render()
6640 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6641 (StatusCode::NOT_FOUND, Html(html)).into_response()
6642 }
6643 }
6644}
6645
6646fn serve_pdf_artifact(
6648 path: &Path,
6649 report_title: &str,
6650 run_id: &str,
6651 wants_download: bool,
6652 csp_nonce: &str,
6653) -> Response {
6654 match fs::read(path) {
6655 Ok(bytes) => {
6656 let filename = build_pdf_filename(report_title, run_id);
6657 let disposition = if wants_download {
6658 format!("attachment; filename=\"{filename}\"")
6659 } else {
6660 format!("inline; filename=\"{filename}\"")
6661 };
6662 (
6663 [
6664 (header::CONTENT_TYPE, "application/pdf".to_string()),
6665 (header::CONTENT_DISPOSITION, disposition),
6666 ],
6667 bytes,
6668 )
6669 .into_response()
6670 }
6671 Err(err) => {
6672 let filename = path.file_name().map_or_else(
6673 || "report.pdf".to_string(),
6674 |n| n.to_string_lossy().into_owned(),
6675 );
6676 let msg = format!(
6677 "PDF report '{filename}' could not be read.\n\n\
6678 Error: {err}\n\n\
6679 If you moved or renamed the output folder, the stored path is now stale. \
6680 Use 'Open PDF folder' from the results page to browse the output directory."
6681 );
6682 let html = ErrorTemplate {
6683 message: msg,
6684 last_report_url: Some("/view-reports".to_string()),
6685 last_report_label: Some("View Reports".to_string()),
6686 run_id: Some(run_id.to_owned()),
6687 error_code: Some(404),
6688 csp_nonce: csp_nonce.to_owned(),
6689 version: env!("CARGO_PKG_VERSION"),
6690 }
6691 .render()
6692 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6693 (StatusCode::NOT_FOUND, Html(html)).into_response()
6694 }
6695 }
6696}
6697
6698fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6700 match fs::read(path) {
6701 Ok(bytes) => {
6702 if wants_download {
6703 (
6704 [
6705 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6706 (
6707 header::CONTENT_DISPOSITION,
6708 "attachment; filename=result.json",
6709 ),
6710 ],
6711 bytes,
6712 )
6713 .into_response()
6714 } else {
6715 (
6716 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6717 bytes,
6718 )
6719 .into_response()
6720 }
6721 }
6722 Err(err) => {
6723 let filename = path.file_name().map_or_else(
6724 || "result.json".to_string(),
6725 |n| n.to_string_lossy().into_owned(),
6726 );
6727 let msg = format!(
6728 "JSON result '{filename}' could not be read.\n\n\
6729 Error: {err}\n\n\
6730 If you moved or renamed the output folder, the stored path is now stale. \
6731 Use 'Open JSON folder' from the results page to browse the output directory."
6732 );
6733 let html = ErrorTemplate {
6734 message: msg,
6735 last_report_url: Some("/view-reports".to_string()),
6736 last_report_label: Some("View Reports".to_string()),
6737 run_id: None,
6738 error_code: Some(404),
6739 csp_nonce: csp_nonce.to_owned(),
6740 version: env!("CARGO_PKG_VERSION"),
6741 }
6742 .render()
6743 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6744 (StatusCode::NOT_FOUND, Html(html)).into_response()
6745 }
6746 }
6747}
6748
6749fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6751 let output_dir = entry
6754 .html_path
6755 .as_ref()
6756 .or(entry.json_path.as_ref())
6757 .or(entry.pdf_path.as_ref())
6758 .or(entry.csv_path.as_ref())
6759 .or(entry.xlsx_path.as_ref())
6760 .and_then(|p| {
6761 let parent = p.parent()?;
6762 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6763 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6765 parent.parent().map(PathBuf::from)
6766 } else {
6767 Some(parent.to_path_buf())
6768 }
6769 })
6770 .unwrap_or_default();
6771 let pdf_path = entry.pdf_path.clone().or_else(|| {
6774 let candidate = output_dir.join("report.pdf");
6775 candidate.exists().then_some(candidate)
6776 });
6777 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6781 for dir in &[output_dir.join("excel"), output_dir.clone()] {
6783 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6784 entries
6785 .filter_map(std::result::Result::ok)
6786 .find(|e| {
6787 let n = e.file_name();
6788 let n = n.to_string_lossy();
6789 n.starts_with("report_") && n.ends_with(ext)
6790 })
6791 .map(|e| e.path())
6792 }) {
6793 return Some(p);
6794 }
6795 }
6796 None
6797 };
6798
6799 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6800 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6801 RunArtifacts {
6802 output_dir: output_dir.clone(),
6803 html_path: entry.html_path.clone(),
6804 pdf_path,
6805 json_path: entry.json_path.clone(),
6806 csv_path,
6807 xlsx_path,
6808 scan_config_path: find_scan_config_in_dir(&output_dir),
6809 report_title: entry.project_label.clone(),
6810 result_context: RunResultContext::default(),
6811 }
6812}
6813
6814#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
6816 state: &AppState,
6817 run_id: &str,
6818 csp_nonce: &str,
6819) -> Result<RunArtifacts, Response> {
6820 let cached = state.artifacts.lock().await.get(run_id).cloned();
6821 if let Some(a) = cached {
6822 return Ok(a);
6823 }
6824 let reg = state.registry.lock().await;
6825 if let Some(entry) = reg.find_by_run_id(run_id) {
6826 return Ok(recover_artifacts_from_registry(entry));
6827 }
6828 drop(reg);
6829 let short_id = &run_id[..run_id.len().min(8)];
6830 let hint = if matches!(
6831 run_id,
6832 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6833 ) {
6834 format!(
6835 " The URL format appears to be reversed \u{2014} \
6836 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6837 Use the View Reports page to navigate to your scan."
6838 )
6839 } else {
6840 " The report may have been deleted or the report directory moved. \
6841 Use View Reports to browse your scan history."
6842 .to_string()
6843 };
6844 let error_html = ErrorTemplate {
6845 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6846 last_report_url: Some("/view-reports".to_string()),
6847 last_report_label: Some("View Reports".to_string()),
6848 run_id: None,
6849 error_code: Some(404),
6850 csp_nonce: csp_nonce.to_owned(),
6851 version: env!("CARGO_PKG_VERSION"),
6852 }
6853 .render()
6854 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6855 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6856}
6857
6858async fn resolve_or_queue_pdf(
6863 state: &AppState,
6864 pdf_path: Option<PathBuf>,
6865 json_path: Option<PathBuf>,
6866 output_dir: PathBuf,
6867 run_id: &str,
6868 report_title: &str,
6869 csp_nonce: &str,
6870) -> Result<PathBuf, Response> {
6871 if let Some(p) = pdf_path {
6872 return Ok(p);
6873 }
6874 let Some(json_src) = json_path.filter(|p| p.exists()) else {
6875 let msg = "PDF report was not generated for this run. \
6876 Re-run the analysis with PDF output enabled."
6877 .to_string();
6878 let html = ErrorTemplate {
6879 message: msg,
6880 last_report_url: Some(format!("/runs/html/{run_id}")),
6881 last_report_label: Some("View HTML Report".to_string()),
6882 run_id: Some(run_id.to_string()),
6883 error_code: Some(404),
6884 csp_nonce: csp_nonce.to_string(),
6885 version: env!("CARGO_PKG_VERSION"),
6886 }
6887 .render()
6888 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6889 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6890 };
6891 let pdf_filename = build_pdf_filename(report_title, run_id);
6892 let pdf_dest = output_dir.join(&pdf_filename);
6893 if !pdf_dest.exists() {
6894 {
6896 let mut map = state.artifacts.lock().await;
6897 if let Some(entry) = map.get_mut(run_id) {
6898 entry.pdf_path = Some(pdf_dest.clone());
6899 }
6900 }
6901 {
6902 let mut reg = state.registry.lock().await;
6903 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6904 e.pdf_path = Some(pdf_dest.clone());
6905 }
6906 let _ = reg.save(&state.registry_path);
6907 }
6908 spawn_native_pdf_background(
6909 json_src,
6910 pdf_dest.clone(),
6911 run_id.to_string(),
6912 state.artifacts.clone(),
6913 );
6914 }
6915 Ok(pdf_dest)
6916}
6917
6918fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6920 let html = format!(
6921 "<!doctype html><html lang=\"en\"><head>\
6922 <meta charset=utf-8>\
6923 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6924 <meta http-equiv=\"refresh\" content=\"5\">\
6925 <title>OxideSLOC | Generating PDF\u{2026}</title>\
6926 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6927 <style nonce=\"{csp_nonce}\">\
6928 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6929 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6930 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6931 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6932 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6933 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6934 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6935 background:var(--bg);color:var(--text);}}\
6936 .top-nav{{position:sticky;top:0;z-index:30;\
6937 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6938 border-bottom:1px solid rgba(255,255,255,0.12);\
6939 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6940 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6941 min-height:56px;display:flex;align-items:center;gap:14px;}}\
6942 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6943 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6944 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6945 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6946 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6947 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6948 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6949 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6950 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6951 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6952 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6953 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6954 justify-content:center;min-height:38px;border-radius:999px;\
6955 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6956 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6957 .theme-toggle .icon-sun{{display:none;}}\
6958 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6959 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6960 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6961 display:flex;align-items:center;justify-content:center;\
6962 min-height:calc(100vh - 56px);}}\
6963 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6964 .panel{{background:var(--surface);border:1px solid var(--line);\
6965 border-radius:var(--radius);box-shadow:var(--shadow);\
6966 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6967 .spin-ring{{width:56px;height:56px;border-radius:50%;\
6968 border:5px solid var(--line);border-top-color:var(--oxide-2);\
6969 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6970 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6971 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6972 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6973 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6974 min-height:42px;padding:0 20px;border-radius:14px;\
6975 border:1px solid var(--line-strong);text-decoration:none;\
6976 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6977 .back-link:hover{{background:var(--line);}}\
6978 </style></head>\
6979 <body>\
6980 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6981 <a class=\"brand\" href=\"/\">\
6982 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6983 <div class=\"brand-copy\">\
6984 <div class=\"brand-title\">OxideSLOC</div>\
6985 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6986 </div>\
6987 </a>\
6988 <div class=\"nav-right\">\
6989 <a class=\"nav-pill\" href=\"/\">Home</a>\
6990 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6991 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6992 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6993 <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>\
6994 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6995 <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>\
6996 </button>\
6997 </div>\
6998 </div></div>\
6999 <div class=\"page\"><div class=\"panel\">\
7000 <div class=\"spin-ring\"></div>\
7001 <h1>Generating PDF\u{2026}</h1>\
7002 <p>The PDF is being generated from the scan results.<br>\
7003 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7004 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7005 </div></div>\
7006 <script nonce=\"{csp_nonce}\">\
7007 (function(){{\
7008 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7009 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7010 var t=document.getElementById(\"theme-toggle\");\
7011 if(t)t.addEventListener(\"click\",function(){{\
7012 var d=b.classList.toggle(\"dark-theme\");\
7013 localStorage.setItem(k,d?\"dark\":\"light\");\
7014 }});\
7015 }})();\
7016 </script>\
7017 </body></html>"
7018 );
7019 Html(html).into_response()
7020}
7021
7022fn render_error_artifact_html(
7024 message: String,
7025 last_report_url: Option<String>,
7026 last_report_label: Option<String>,
7027 run_id: Option<String>,
7028 error_code: Option<u16>,
7029 csp_nonce: &str,
7030) -> String {
7031 ErrorTemplate {
7032 message,
7033 last_report_url,
7034 last_report_label,
7035 run_id,
7036 error_code,
7037 csp_nonce: csp_nonce.to_owned(),
7038 version: env!("CARGO_PKG_VERSION"),
7039 }
7040 .render()
7041 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7042}
7043
7044fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7046 fs::read(path).map_or_else(
7047 |_| StatusCode::NOT_FOUND.into_response(),
7048 |bytes| {
7049 let filename = path.file_name().map_or_else(
7050 || fallback_filename.to_string(),
7051 |n| n.to_string_lossy().into_owned(),
7052 );
7053 (
7054 [
7055 (header::CONTENT_TYPE, content_type.to_string()),
7056 (
7057 header::CONTENT_DISPOSITION,
7058 format!("attachment; filename=\"{filename}\""),
7059 ),
7060 ],
7061 bytes,
7062 )
7063 .into_response()
7064 },
7065 )
7066}
7067
7068fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7069 let Some(path) = csv_path else {
7070 let html = render_error_artifact_html(
7071 "CSV report was not generated for this run, or was not recorded in \
7072 the scan registry."
7073 .to_string(),
7074 Some(format!("/runs/html/{run_id}")),
7075 Some("View HTML Report".to_string()),
7076 Some(run_id.to_string()),
7077 Some(404),
7078 csp_nonce,
7079 );
7080 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7081 };
7082 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7083}
7084
7085fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7086 let Some(path) = xlsx_path else {
7087 let html = render_error_artifact_html(
7088 "Excel report was not generated for this run, or was not recorded in \
7089 the scan registry."
7090 .to_string(),
7091 Some(format!("/runs/html/{run_id}")),
7092 Some("View HTML Report".to_string()),
7093 Some(run_id.to_string()),
7094 Some(404),
7095 csp_nonce,
7096 );
7097 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7098 };
7099 serve_binary_download(
7100 &path,
7101 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7102 "report.xlsx",
7103 )
7104}
7105
7106fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7107 let path = artifact_set
7108 .scan_config_path
7109 .as_deref()
7110 .map(std::path::Path::to_path_buf)
7111 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7112 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7113 fs::read(&path).map_or_else(
7114 |_| StatusCode::NOT_FOUND.into_response(),
7115 |bytes| {
7116 (
7117 [
7118 (
7119 header::CONTENT_TYPE,
7120 "application/json; charset=utf-8".to_string(),
7121 ),
7122 (
7123 header::CONTENT_DISPOSITION,
7124 "attachment; filename=\"scan-config.json\"".to_string(),
7125 ),
7126 ],
7127 bytes,
7128 )
7129 .into_response()
7130 },
7131 )
7132}
7133
7134async fn serve_submodule_pdf_arm(
7139 artifact: &str,
7140 artifact_set: RunArtifacts,
7141 wants_download: bool,
7142 run_id: &str,
7143 csp_nonce: &str,
7144) -> Response {
7145 let base = artifact.trim_end_matches("_pdf");
7147 let sub_dir = artifact_set.output_dir.join("submodules");
7148 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7149
7150 if !pdf_path.exists() {
7151 let derived_safe = base.trim_start_matches("sub_");
7153 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7154 let parent_run = read_json(jp).ok()?;
7155 let sub = parent_run
7156 .submodule_summaries
7157 .iter()
7158 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7159 .clone();
7160 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7161 Some((parent_run, sub, parent_path))
7162 });
7163
7164 if let Some((parent_run, sub, parent_path)) = rebuilt {
7165 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7166 let pp = pdf_path.clone();
7167 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7168 }
7169 }
7170
7171 if !pdf_path.exists() {
7172 let html = render_error_artifact_html(
7173 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7174 enabled."
7175 .to_string(),
7176 Some("/view-reports".to_string()),
7177 Some("View Reports".to_string()),
7178 Some(run_id.to_string()),
7179 Some(404),
7180 csp_nonce,
7181 );
7182 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7183 }
7184
7185 serve_pdf_artifact(
7186 &pdf_path,
7187 &artifact_set.report_title,
7188 run_id,
7189 wants_download,
7190 csp_nonce,
7191 )
7192}
7193
7194fn serve_submodule_arm(
7195 artifact: &str,
7196 artifact_set: &RunArtifacts,
7197 wants_download: bool,
7198 csp_nonce: &str,
7199 run_id: &str,
7200 server_mode: bool,
7201) -> Response {
7202 if artifact.len() > 128
7203 || !artifact
7204 .chars()
7205 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7206 {
7207 return StatusCode::BAD_REQUEST.into_response();
7208 }
7209 let filename = format!("{artifact}.html");
7210 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7212 let path = if new_layout.exists() {
7213 new_layout
7214 } else {
7215 artifact_set.output_dir.join(&filename)
7216 };
7217 if !path.exists() {
7218 let html = render_error_artifact_html(
7219 format!(
7220 "Sub-report '{artifact}' was not found in the run directory.\n\
7221 Re-run the analysis with 'Detect and separate git submodules' \
7222 and HTML output enabled."
7223 ),
7224 Some("/view-reports".to_string()),
7225 Some("View Reports".to_string()),
7226 Some(run_id.to_string()),
7227 Some(404),
7228 csp_nonce,
7229 );
7230 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7231 }
7232 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7233}
7234
7235async fn serve_pdf_arm(
7236 state: &AppState,
7237 artifact_set: RunArtifacts,
7238 wants_download: bool,
7239 run_id: &str,
7240 csp_nonce: &str,
7241) -> Response {
7242 let report_title = artifact_set.report_title.clone();
7243 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7244 let stale_html_name = artifact_set
7245 .html_path
7246 .as_deref()
7247 .and_then(|p| p.file_name())
7248 .map(|n| n.to_string_lossy().into_owned());
7249 let path = match resolve_or_queue_pdf(
7250 state,
7251 artifact_set.pdf_path,
7252 artifact_set.json_path.clone(),
7253 artifact_set.output_dir.clone(),
7254 run_id,
7255 &report_title,
7256 csp_nonce,
7257 )
7258 .await
7259 {
7260 Ok(p) => p,
7261 Err(r) => return r,
7262 };
7263 if !path.exists() {
7264 if had_pdf_in_registry {
7268 if let Some(expected_filename) = stale_html_name {
7269 let html = LocateFileTemplate {
7270 run_id: run_id.to_string(),
7271 artifact_type: "pdf".to_string(),
7272 expected_filename,
7273 server_mode: state.server_mode,
7274 csp_nonce: csp_nonce.to_string(),
7275 version: env!("CARGO_PKG_VERSION"),
7276 }
7277 .render()
7278 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7279 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7280 }
7281 }
7282 return pdf_generating_response(run_id, csp_nonce);
7283 }
7284 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7285}
7286
7287async fn artifact_handler(
7288 State(state): State<AppState>,
7289 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7290 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7291 Query(query): Query<ArtifactQuery>,
7292) -> Response {
7293 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7294 Ok(a) => a,
7295 Err(r) => return r,
7296 };
7297
7298 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7299
7300 match artifact.as_str() {
7301 "html" => {
7302 let Some(path) = artifact_set.html_path else {
7303 return StatusCode::NOT_FOUND.into_response();
7304 };
7305 serve_html_artifact(
7306 &path,
7307 wants_download,
7308 &csp_nonce,
7309 &run_id,
7310 state.server_mode,
7311 )
7312 }
7313 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7314 "json" => {
7315 let Some(path) = artifact_set.json_path else {
7316 let html = render_error_artifact_html(
7317 "JSON result was not generated for this run, or was not recorded in \
7318 the scan registry. Re-run the analysis with JSON output enabled."
7319 .to_string(),
7320 Some("/view-reports".to_string()),
7321 Some("View Reports".to_string()),
7322 Some(run_id.clone()),
7323 Some(404),
7324 &csp_nonce,
7325 );
7326 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7327 };
7328 serve_json_artifact(&path, wants_download, &csp_nonce)
7329 }
7330 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7331 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7332 "scan-config" => serve_scan_config_arm(&artifact_set),
7333 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7334 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7335 .await
7336 }
7337 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7338 &artifact,
7339 &artifact_set,
7340 wants_download,
7341 &csp_nonce,
7342 &run_id,
7343 state.server_mode,
7344 ),
7345 _ => StatusCode::NOT_FOUND.into_response(),
7346 }
7347}
7348
7349struct SubmoduleLinkRow {
7352 name: String,
7353 url: String,
7354}
7355
7356struct HistoryEntryRow {
7357 run_id: String,
7358 run_id_short: String,
7359 timestamp: String,
7360 timestamp_utc_ms: i64,
7361 project_label: String,
7362 project_path: String,
7363 files_analyzed: u64,
7364 files_skipped: u64,
7365 code_lines: u64,
7366 comment_lines: u64,
7367 blank_lines: u64,
7368 total_physical_lines: u64,
7369 functions: u64,
7370 classes: u64,
7371 variables: u64,
7372 imports: u64,
7373 test_count: u64,
7374 git_branch: String,
7375 git_commit: String,
7376 git_commit_long: String,
7378 has_html: bool,
7379 has_json: bool,
7380 has_pdf: bool,
7381 submodule_links: Vec<SubmoduleLinkRow>,
7382 submodule_names_csv: String,
7384}
7385
7386fn nth_weekday_of_month(
7388 year: i32,
7389 month: u32,
7390 weekday: chrono::Weekday,
7391 n: u32,
7392) -> chrono::NaiveDate {
7393 use chrono::Datelike;
7394 let mut count = 0u32;
7395 let mut day = 1u32;
7396 loop {
7397 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7398 if d.weekday() == weekday {
7399 count += 1;
7400 if count == n {
7401 return d;
7402 }
7403 }
7404 day += 1;
7405 }
7406}
7407
7408fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7412 use chrono::{Datelike, TimeZone};
7413 let year = dt.year();
7414 let dst_start = chrono::Utc.from_utc_datetime(
7415 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7416 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7417 );
7418 let dst_end = chrono::Utc.from_utc_datetime(
7419 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7420 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7421 );
7422 dt >= dst_start && dt < dst_end
7423}
7424
7425fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7426 if is_pacific_dst(dt) {
7427 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7428 .format("%Y-%m-%d %H:%M PDT")
7429 .to_string()
7430 } else {
7431 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7432 .format("%Y-%m-%d %H:%M PST")
7433 .to_string()
7434 }
7435}
7436
7437fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7439 let (offset, tz) = if is_pacific_dst(dt) {
7440 (
7441 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7442 "PDT",
7443 )
7444 } else {
7445 (
7446 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7447 "PST",
7448 )
7449 };
7450 format!(
7451 "{} {tz}",
7452 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7453 )
7454}
7455
7456fn fmt_git_date(iso: &str) -> Option<String> {
7457 chrono::DateTime::parse_from_rfc3339(iso)
7458 .ok()
7459 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7460}
7461
7462fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7471 use std::io::{Read, Seek, SeekFrom};
7472 if short.is_empty() {
7473 return None;
7474 }
7475 let len = std::fs::metadata(path).ok()?.len();
7476 const TAIL: u64 = 4 * 1024 * 1024; let start = len.saturating_sub(TAIL);
7478 let mut file = std::fs::File::open(path).ok()?;
7479 file.seek(SeekFrom::Start(start)).ok()?;
7480 let mut buf = Vec::new();
7481 file.read_to_end(&mut buf).ok()?;
7482 let text = String::from_utf8_lossy(&buf);
7483 let short_lower = short.to_ascii_lowercase();
7484 let key = "\"git_commit_long\"";
7485 let mut found: Option<String> = None;
7486 let mut cursor = 0usize;
7487 while let Some(idx) = text[cursor..].find(key) {
7488 let after_key = cursor + idx + key.len();
7489 cursor = after_key;
7490 let rest = &text[after_key..];
7491 let Some(colon) = rest.find(':') else { break };
7492 let value_region = rest[colon + 1..].trim_start();
7493 if let Some(open) = value_region.strip_prefix('"') {
7495 if let Some(close) = open.find('"') {
7496 let val = &open[..close];
7497 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7498 found = Some(val.to_string());
7499 }
7500 }
7501 }
7502 }
7503 found
7504}
7505
7506fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7507 reg.entries
7508 .iter()
7509 .map(|e| {
7510 let submodule_links = {
7511 let mut links: Vec<SubmoduleLinkRow> = vec![];
7512 let sub_dir = e
7513 .html_path
7514 .as_ref()
7515 .and_then(|p| p.parent())
7516 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7517 if let Some(dir) = sub_dir {
7518 if let Ok(rd) = std::fs::read_dir(dir) {
7519 for entry_res in rd.flatten() {
7520 let fname = entry_res.file_name();
7521 let fname_str = fname.to_string_lossy();
7522 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7523 let stem = &fname_str[..fname_str.len() - 5];
7524 let display = stem[4..].replace('-', " ");
7525 links.push(SubmoduleLinkRow {
7526 name: display,
7527 url: format!("/runs/{stem}/{}", e.run_id),
7528 });
7529 }
7530 }
7531 }
7532 }
7533 links.sort_by(|a, b| a.name.cmp(&b.name));
7534 links
7535 };
7536 let submodule_names_csv = submodule_links
7537 .iter()
7538 .map(|l| l.name.as_str())
7539 .collect::<Vec<_>>()
7540 .join(",");
7541 HistoryEntryRow {
7542 run_id: e.run_id.clone(),
7543 run_id_short: e
7544 .run_id
7545 .split('-')
7546 .next_back()
7547 .unwrap_or(&e.run_id)
7548 .chars()
7549 .take(7)
7550 .collect(),
7551 timestamp: fmt_la_time(e.timestamp_utc),
7552 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7553 project_label: e.project_label.clone(),
7554 project_path: e
7555 .input_roots
7556 .first()
7557 .map(|s| sanitize_path_str(s))
7558 .unwrap_or_default(),
7559 files_analyzed: e.summary.files_analyzed,
7560 files_skipped: e.summary.files_skipped,
7561 code_lines: e.summary.code_lines,
7562 comment_lines: e.summary.comment_lines,
7563 blank_lines: e.summary.blank_lines,
7564 total_physical_lines: e.summary.total_physical_lines,
7565 functions: e.summary.functions,
7566 classes: e.summary.classes,
7567 variables: e.summary.variables,
7568 imports: e.summary.imports,
7569 test_count: e.summary.test_count,
7570 git_branch: e.git_branch.clone().unwrap_or_default(),
7571 git_commit: e.git_commit.clone().unwrap_or_default(),
7572 git_commit_long: {
7573 let short = e.git_commit.clone().unwrap_or_default();
7574 e.git_commit_long
7575 .clone()
7576 .filter(|s| !s.is_empty())
7577 .or_else(|| {
7578 e.json_path
7579 .as_ref()
7580 .and_then(|p| extract_long_commit_from_json(p, &short))
7581 })
7582 .unwrap_or(short)
7583 },
7584 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7585 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7586 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7587 submodule_links,
7588 submodule_names_csv,
7589 }
7590 })
7591 .collect()
7592}
7593
7594#[derive(Deserialize, Default)]
7595struct HistoryQuery {
7596 linked: Option<String>,
7597 error: Option<String>,
7598}
7599
7600async fn history_handler(
7601 State(state): State<AppState>,
7602 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7603 Query(query): Query<HistoryQuery>,
7604) -> impl IntoResponse {
7605 auto_scan_watched_dirs(&state).await;
7607 let watched_dirs: Vec<String> = {
7608 let wd = state.watched_dirs.lock().await;
7609 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7610 };
7611 let mut entries = {
7612 let reg = state.registry.lock().await;
7613 make_history_rows(®)
7614 };
7615 entries.retain(|e| e.has_html);
7616 let total_scans = entries.len();
7617 let linked_count = query
7618 .linked
7619 .as_deref()
7620 .and_then(|s| s.parse::<usize>().ok())
7621 .unwrap_or(0);
7622 let browse_error = query.error.filter(|s| !s.is_empty());
7623 let template = HistoryTemplate {
7624 version: env!("CARGO_PKG_VERSION"),
7625 entries,
7626 total_scans,
7627 linked_count,
7628 browse_error,
7629 watched_dirs,
7630 csp_nonce,
7631 server_mode: state.server_mode,
7632 };
7633 Html(
7634 template
7635 .render()
7636 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7637 )
7638 .into_response()
7639}
7640
7641async fn compare_select_handler(
7642 State(state): State<AppState>,
7643 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7644) -> impl IntoResponse {
7645 auto_scan_watched_dirs(&state).await;
7646 let watched_dirs: Vec<String> = {
7647 let wd = state.watched_dirs.lock().await;
7648 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7649 };
7650 let mut entries = {
7651 let reg = state.registry.lock().await;
7652 make_history_rows(®)
7653 };
7654 entries.retain(|e| e.has_json);
7655 let total_scans = entries.len();
7656 let template = CompareSelectTemplate {
7657 version: env!("CARGO_PKG_VERSION"),
7658 entries,
7659 total_scans,
7660 watched_dirs,
7661 csp_nonce,
7662 server_mode: state.server_mode,
7663 };
7664 Html(
7665 template
7666 .render()
7667 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7668 )
7669 .into_response()
7670}
7671
7672#[derive(Deserialize, Default)]
7675struct CompareQuery {
7676 a: Option<String>,
7677 b: Option<String>,
7678 sub: Option<String>,
7680 scope: Option<String>,
7682}
7683
7684struct CompareFileDeltaRow {
7685 relative_path: String,
7686 language: String,
7687 status: String,
7688 baseline_code: i64,
7689 current_code: i64,
7690 baseline_code_display: String,
7691 current_code_display: String,
7692 code_delta_str: String,
7693 code_delta_class: String,
7694 comment_delta_str: String,
7695 comment_delta_class: String,
7696 total_delta_str: String,
7697 total_delta_class: String,
7698}
7699
7700fn recompute_summary_from_records(run: &mut AnalysisRun) {
7703 let mut totals = SummaryTotals::default();
7704 for r in &run.per_file_records {
7705 if r.language.is_some() {
7706 totals.files_analyzed += 1;
7707 }
7708 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7709 totals.code_lines += r.effective_counts.code_lines;
7710 totals.comment_lines += r.effective_counts.comment_lines;
7711 totals.blank_lines += r.effective_counts.blank_lines;
7712 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7713 totals.functions += r.raw_line_categories.functions;
7714 totals.classes += r.raw_line_categories.classes;
7715 totals.variables += r.raw_line_categories.variables;
7716 totals.imports += r.raw_line_categories.imports;
7717 totals.test_count += r.raw_line_categories.test_count;
7718 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7719 totals.test_suite_count += r.raw_line_categories.test_suite_count;
7720 if let Some(cov) = &r.coverage {
7721 totals.coverage_lines_found += u64::from(cov.lines_found);
7722 totals.coverage_lines_hit += u64::from(cov.lines_hit);
7723 totals.coverage_functions_found += u64::from(cov.functions_found);
7724 totals.coverage_functions_hit += u64::from(cov.functions_hit);
7725 totals.coverage_branches_found += u64::from(cov.branches_found);
7726 totals.coverage_branches_hit += u64::from(cov.branches_hit);
7727 }
7728 }
7729 totals.files_considered = totals.files_analyzed;
7730 run.summary_totals = totals;
7731}
7732
7733fn fmt_delta(n: i64) -> String {
7734 if n > 0 {
7735 format!("+{n}")
7736 } else {
7737 format!("{n}")
7738 }
7739}
7740
7741fn delta_class(n: i64) -> &'static str {
7742 use std::cmp::Ordering;
7743 match n.cmp(&0) {
7744 Ordering::Greater => "pos",
7745 Ordering::Less => "neg",
7746 Ordering::Equal => "zero",
7747 }
7748}
7749
7750#[allow(clippy::cast_precision_loss)]
7752fn fmt_pct(delta: i64, baseline: u64) -> String {
7753 if baseline == 0 {
7754 return "—".to_string();
7755 }
7756 #[allow(clippy::cast_precision_loss)]
7757 let pct = (delta as f64 / baseline as f64) * 100.0;
7758 if pct > 0.049 {
7759 format!("+{pct:.1}%")
7760 } else if pct < -0.049 {
7761 format!("{pct:.1}%")
7762 } else {
7763 "±0%".to_string()
7764 }
7765}
7766
7767fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7769 prev.map_or_else(
7770 || ("—".to_string(), "na"),
7771 |p| {
7772 #[allow(clippy::cast_possible_wrap)]
7773 let d = curr as i64 - p as i64;
7774 (fmt_delta(d), delta_class(d))
7775 },
7776 )
7777}
7778
7779#[allow(clippy::result_large_err)] fn load_scan_for_compare(
7781 json_path: &std::path::Path,
7782 scan_label: &str,
7783 run_id: &str,
7784 server_mode: bool,
7785 compare_url: &str,
7786 csp_nonce: &str,
7787) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7788 match read_json(json_path) {
7789 Ok(r) => Ok(r),
7790 Err(e) => {
7791 if server_mode {
7792 let html = ErrorTemplate {
7793 message: format!(
7794 "Could not load {scan_label} scan data. The scan output folder may have \
7795 been moved, renamed, or deleted. Re-running the analysis will create \
7796 fresh comparison data."
7797 ),
7798 last_report_url: Some("/compare-scans".to_string()),
7799 last_report_label: Some("Compare Scans".to_string()),
7800 run_id: Some(run_id.to_owned()),
7801 error_code: Some(404),
7802 csp_nonce: csp_nonce.to_owned(),
7803 version: env!("CARGO_PKG_VERSION"),
7804 }
7805 .render()
7806 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7807 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7808 }
7809 let msg = format!(
7810 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7811 json_path.display()
7812 );
7813 let folder_hint = output_folder_hint(json_path);
7814 Err(missing_scan_relocate_response(
7815 &msg,
7816 run_id,
7817 &folder_hint,
7818 compare_url,
7819 false,
7820 csp_nonce,
7821 ))
7822 }
7823 }
7824}
7825
7826struct ChurnStats {
7827 new_scope: bool,
7828 scope_flag: bool,
7829 churn_rate_str: String,
7830 churn_rate_class: String,
7831}
7832
7833fn compute_churn_stats(
7834 baseline_code: u64,
7835 current_code: u64,
7836 lines_added: i64,
7837 lines_removed: i64,
7838) -> ChurnStats {
7839 let new_scope = baseline_code == 0 && current_code > 0;
7840 #[allow(clippy::cast_precision_loss)]
7841 let churn_pct = if baseline_code > 0 {
7842 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7843 } else {
7844 0.0
7845 };
7846 #[allow(clippy::cast_precision_loss)]
7847 let scope_flag =
7848 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7849 let churn_rate_str = if new_scope {
7850 "New".to_string()
7851 } else if baseline_code > 0 {
7852 format!("{churn_pct:.1}%")
7853 } else {
7854 "—".to_string()
7855 };
7856 let churn_rate_class = if new_scope || churn_pct > 20.0 {
7857 "high".to_string()
7858 } else if churn_pct > 5.0 {
7859 "med".to_string()
7860 } else {
7861 "low".to_string()
7862 };
7863 ChurnStats {
7864 new_scope,
7865 scope_flag,
7866 churn_rate_str,
7867 churn_rate_class,
7868 }
7869}
7870
7871fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7875 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7876 if !has_data {
7877 return String::new();
7878 }
7879 let base_str = s
7880 .baseline_coverage_line_pct
7881 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7882 let curr_str = s
7883 .current_coverage_line_pct
7884 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7885 let (delta_str, cls) = match s.coverage_line_pct_delta {
7886 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7887 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7888 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7889 None => ("\u{2014}".into(), "zero"),
7890 };
7891 format!(
7892 r#"<div class="delta-card">
7893 <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>
7894 <div class="delta-card-label">Line coverage</div>
7895 <div class="delta-card-from">Before: {base_str}</div>
7896 <div class="delta-card-to">{curr_str}</div>
7897 <span class="delta-card-change {cls}">{delta_str}</span>
7898 </div>"#
7899 )
7900}
7901
7902#[allow(clippy::ref_option)]
7904fn narrow_run_pair_by_scope(
7905 mut baseline: AnalysisRun,
7906 mut current: AnalysisRun,
7907 active_sub: &Option<String>,
7908 super_scope: bool,
7909) -> (AnalysisRun, AnalysisRun) {
7910 if let Some(ref sub_name) = active_sub {
7911 baseline
7912 .per_file_records
7913 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7914 current
7915 .per_file_records
7916 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7917 recompute_summary_from_records(&mut baseline);
7918 recompute_summary_from_records(&mut current);
7919 } else if super_scope {
7920 baseline.per_file_records.retain(|f| f.submodule.is_none());
7921 current.per_file_records.retain(|f| f.submodule.is_none());
7922 recompute_summary_from_records(&mut baseline);
7923 recompute_summary_from_records(&mut current);
7924 }
7925 (baseline, current)
7926}
7927
7928#[allow(clippy::ref_option)]
7930fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7931 if let Some(ref sub_name) = active_sub {
7932 for run in runs.iter_mut() {
7933 run.per_file_records
7934 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7935 recompute_summary_from_records(run);
7936 }
7937 } else if super_scope {
7938 for run in runs.iter_mut() {
7939 run.per_file_records.retain(|f| f.submodule.is_none());
7940 recompute_summary_from_records(run);
7941 }
7942 }
7943}
7944
7945#[allow(clippy::too_many_lines)]
7946async fn compare_handler(
7947 State(state): State<AppState>,
7948 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7949 Query(query): Query<CompareQuery>,
7950) -> impl IntoResponse {
7951 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7954 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7955 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7956 };
7957
7958 let (maybe_a, maybe_b) = {
7959 let reg = state.registry.lock().await;
7960 (
7961 reg.find_by_run_id(&run_id_a).cloned(),
7962 reg.find_by_run_id(&run_id_b).cloned(),
7963 )
7964 };
7965
7966 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7967 let html = ErrorTemplate {
7968 message: "One or both run IDs were not found in scan history. \
7969 The runs may have been deleted or the registry may have been reset."
7970 .to_string(),
7971 last_report_url: Some("/compare-scans".to_string()),
7972 last_report_label: Some("Compare Scans".to_string()),
7973 run_id: None,
7974 error_code: None,
7975 csp_nonce: csp_nonce.clone(),
7976 version: env!("CARGO_PKG_VERSION"),
7977 }
7978 .render()
7979 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7980 return Html(html).into_response();
7981 };
7982
7983 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7985 (entry_a, entry_b)
7986 } else {
7987 (entry_b, entry_a)
7988 };
7989
7990 if baseline_entry.run_id != run_id_a {
7994 let canonical = format!(
7995 "/compare?a={}&b={}",
7996 baseline_entry.run_id, current_entry.run_id
7997 );
7998 return axum::response::Redirect::to(&canonical).into_response();
7999 }
8000
8001 let (Some(base_json), Some(curr_json)) = (
8002 baseline_entry.json_path.as_ref(),
8003 current_entry.json_path.as_ref(),
8004 ) else {
8005 let html = ErrorTemplate {
8006 message: "Full comparison requires JSON scan data, which was not saved for one or \
8007 both of these runs. JSON is now always saved for new scans — re-run the \
8008 affected projects to enable comparisons."
8009 .to_string(),
8010 last_report_url: Some("/compare-scans".to_string()),
8011 last_report_label: Some("Compare Scans".to_string()),
8012 run_id: None,
8013 error_code: None,
8014 csp_nonce: csp_nonce.clone(),
8015 version: env!("CARGO_PKG_VERSION"),
8016 }
8017 .render()
8018 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8019 return Html(html).into_response();
8020 };
8021
8022 let compare_url = format!(
8023 "/compare?a={}&b={}",
8024 baseline_entry.run_id, current_entry.run_id
8025 );
8026
8027 let baseline_run = match load_scan_for_compare(
8028 base_json,
8029 "baseline",
8030 &baseline_entry.run_id,
8031 state.server_mode,
8032 &compare_url,
8033 &csp_nonce,
8034 ) {
8035 Ok(r) => r,
8036 Err(resp) => return resp,
8037 };
8038 let current_run = match load_scan_for_compare(
8039 curr_json,
8040 "current",
8041 ¤t_entry.run_id,
8042 state.server_mode,
8043 &compare_url,
8044 &csp_nonce,
8045 ) {
8046 Ok(r) => r,
8047 Err(resp) => return resp,
8048 };
8049
8050 let active_submodule = query.sub.clone();
8051 let super_scope_active = query.scope.as_deref() == Some("super");
8052
8053 let submodule_options = baseline_run
8054 .submodule_summaries
8055 .iter()
8056 .chain(current_run.submodule_summaries.iter())
8057 .map(|s| s.name.clone())
8058 .collect::<std::collections::BTreeSet<_>>()
8059 .into_iter()
8060 .collect::<Vec<_>>();
8061 let has_any_submodule_data = !submodule_options.is_empty();
8062
8063 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8065 baseline_run,
8066 current_run,
8067 &active_submodule,
8068 super_scope_active,
8069 );
8070
8071 let comparison = compute_delta(&effective_baseline, &effective_current);
8072
8073 let file_rows: Vec<CompareFileDeltaRow> = comparison
8074 .file_deltas
8075 .iter()
8076 .map(|d| CompareFileDeltaRow {
8077 relative_path: d.relative_path.clone(),
8078 language: d.language.clone().unwrap_or_else(|| "—".into()),
8079 status: match d.status {
8080 FileChangeStatus::Added => "added".into(),
8081 FileChangeStatus::Removed => "removed".into(),
8082 FileChangeStatus::Modified => "modified".into(),
8083 FileChangeStatus::Unchanged => "unchanged".into(),
8084 },
8085 baseline_code: d.baseline_code,
8086 current_code: d.current_code,
8087 baseline_code_display: if d.status == FileChangeStatus::Added {
8088 "—".into()
8089 } else {
8090 d.baseline_code.to_string()
8091 },
8092 current_code_display: if d.status == FileChangeStatus::Removed {
8093 "—".into()
8094 } else {
8095 d.current_code.to_string()
8096 },
8097 code_delta_str: fmt_delta(d.code_delta),
8098 code_delta_class: delta_class(d.code_delta).into(),
8099 comment_delta_str: fmt_delta(d.comment_delta),
8100 comment_delta_class: delta_class(d.comment_delta).into(),
8101 total_delta_str: fmt_delta(d.total_delta),
8102 total_delta_class: delta_class(d.total_delta).into(),
8103 })
8104 .collect();
8105
8106 let project_path = baseline_entry
8107 .input_roots
8108 .first()
8109 .map(|s| sanitize_path_str(s))
8110 .unwrap_or_default();
8111 let lines_added = sum_added_code_lines(&comparison);
8112 let lines_removed = sum_removed_code_lines(&comparison);
8113 let churn = compute_churn_stats(
8114 comparison.summary.baseline_code,
8115 comparison.summary.current_code,
8116 lines_added,
8117 lines_removed,
8118 );
8119 let s = &comparison.summary;
8120 let template = CompareTemplate {
8121 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8122 version: env!("CARGO_PKG_VERSION"),
8123 project_label: baseline_entry.project_label.clone(),
8124 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8125 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8126 baseline_run_id: baseline_entry.run_id.clone(),
8127 current_run_id: current_entry.run_id.clone(),
8128 baseline_run_id_short: baseline_entry
8129 .run_id
8130 .split('-')
8131 .next_back()
8132 .unwrap_or(&baseline_entry.run_id)
8133 .chars()
8134 .take(7)
8135 .collect(),
8136 current_run_id_short: current_entry
8137 .run_id
8138 .split('-')
8139 .next_back()
8140 .unwrap_or(¤t_entry.run_id)
8141 .chars()
8142 .take(7)
8143 .collect(),
8144 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8145 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8146 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8147 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8148 project_path: project_path.clone(),
8149 baseline_code: s.baseline_code,
8150 current_code: s.current_code,
8151 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8152 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8153 baseline_files: s.baseline_files,
8154 current_files: s.current_files,
8155 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8156 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8157 baseline_comments: s.baseline_comments,
8158 current_comments: s.current_comments,
8159 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8160 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8161 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8162 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8163 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8164 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8165 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8166 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8167 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8168 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8169 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8170 code_lines_added: lines_added,
8171 code_lines_removed: lines_removed,
8172 new_scope: churn.new_scope,
8173 churn_rate_str: churn.churn_rate_str,
8174 churn_rate_class: churn.churn_rate_class,
8175 scope_flag: churn.scope_flag,
8176 files_added: comparison.files_added,
8177 files_removed: comparison.files_removed,
8178 files_modified: comparison.files_modified,
8179 files_unchanged: comparison.files_unchanged,
8180 file_rows,
8181 baseline_git_author: baseline_entry.git_author.clone(),
8182 current_git_author: current_entry.git_author.clone(),
8183 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8184 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8185 baseline_git_tags: baseline_entry.git_tags.clone(),
8186 current_git_tags: current_entry.git_tags.clone(),
8187 baseline_git_commit_date: baseline_entry
8188 .git_commit_date
8189 .as_deref()
8190 .and_then(fmt_git_date),
8191 current_git_commit_date: current_entry
8192 .git_commit_date
8193 .as_deref()
8194 .and_then(fmt_git_date),
8195 project_name: project_path
8196 .rsplit(['/', '\\'])
8197 .find(|s| !s.is_empty())
8198 .unwrap_or(&project_path)
8199 .to_string(),
8200 submodule_options,
8201 has_any_submodule_data,
8202 active_submodule,
8203 super_scope_active,
8204 toast_assets: sloc_toast_assets(&csp_nonce),
8205 csp_nonce,
8206 coverage_delta_card: build_coverage_delta_card(s),
8207 baseline_test_count: effective_baseline.summary_totals.test_count,
8208 current_test_count: effective_current.summary_totals.test_count,
8209 baseline_coverage_pct: s.baseline_coverage_line_pct,
8210 current_coverage_pct: s.current_coverage_line_pct,
8211 };
8212
8213 Html(
8214 template
8215 .render()
8216 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8217 )
8218 .into_response()
8219}
8220
8221fn format_number(n: u64) -> String {
8229 let s = n.to_string();
8230 let mut out = String::with_capacity(s.len() + s.len() / 3);
8231 let len = s.len();
8232 for (i, c) in s.chars().enumerate() {
8233 if i > 0 && (len - i).is_multiple_of(3) {
8234 out.push(',');
8235 }
8236 out.push(c);
8237 }
8238 out
8239}
8240
8241const fn badge_char_width(c: char) -> f64 {
8242 match c {
8243 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8244 'm' | 'w' => 9.0,
8245 ' ' => 4.0,
8246 _ => 6.5,
8247 }
8248}
8249
8250#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8251fn badge_text_px(text: &str) -> u32 {
8252 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8253}
8254
8255fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8256 let lw = badge_text_px(label) + 20;
8257 let rw = badge_text_px(value) + 20;
8258 let total = lw + rw;
8259 let lx = lw / 2;
8260 let rx = lw + rw / 2;
8261 let le = escape_html(label);
8262 let ve = escape_html(value);
8263 let ce = escape_html(color);
8264 format!(
8265 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8266 <rect width="{total}" height="20" fill="#555"/>
8267 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8268 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8269 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8270 <text x="{lx}" y="13">{le}</text>
8271 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8272 <text x="{rx}" y="13">{ve}</text>
8273 </g>
8274</svg>"##
8275 )
8276}
8277
8278#[derive(Deserialize)]
8279struct BadgeQuery {
8280 label: Option<String>,
8281 color: Option<String>,
8282}
8283
8284async fn badge_handler(
8285 State(state): State<AppState>,
8286 AxumPath(metric): AxumPath<String>,
8287 Query(query): Query<BadgeQuery>,
8288) -> Response {
8289 let entry = {
8290 let reg = state.registry.lock().await;
8291 reg.entries.first().cloned()
8292 };
8293
8294 let Some(entry) = entry else {
8295 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8296 return (
8297 [
8298 (header::CONTENT_TYPE, "image/svg+xml"),
8299 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8300 ],
8301 svg,
8302 )
8303 .into_response();
8304 };
8305
8306 let (default_label, value, default_color) = match metric.as_str() {
8307 "code-lines" => (
8308 "code lines",
8309 format_number(entry.summary.code_lines),
8310 "#4a78ee",
8311 ),
8312 "files" => (
8313 "files analyzed",
8314 format_number(entry.summary.files_analyzed),
8315 "#4a9862",
8316 ),
8317 "comment-lines" => (
8318 "comment lines",
8319 format_number(entry.summary.comment_lines),
8320 "#b35428",
8321 ),
8322 "blank-lines" => (
8323 "blank lines",
8324 format_number(entry.summary.blank_lines),
8325 "#7a5db0",
8326 ),
8327 _ => return StatusCode::NOT_FOUND.into_response(),
8328 };
8329
8330 let label = query.label.as_deref().unwrap_or(default_label);
8331 let color = query.color.as_deref().unwrap_or(default_color);
8332 let svg = render_badge_svg(label, &value, color);
8333
8334 (
8335 [
8336 (header::CONTENT_TYPE, "image/svg+xml"),
8337 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8338 ],
8339 svg,
8340 )
8341 .into_response()
8342}
8343
8344#[derive(Serialize)]
8352struct ApiCoverageBlock {
8353 lines_found: u64,
8354 lines_hit: u64,
8355 line_pct: f64,
8356 functions_found: u64,
8357 functions_hit: u64,
8358 function_pct: f64,
8359 branches_found: u64,
8360 branches_hit: u64,
8361 branch_pct: f64,
8362}
8363
8364#[derive(Serialize)]
8365struct ApiMetricsResponse {
8366 run_id: String,
8367 timestamp: String,
8368 project: String,
8369 summary: ApiSummaryPayload,
8370 languages: Vec<ApiLanguageRow>,
8371 #[serde(skip_serializing_if = "Option::is_none")]
8372 coverage: Option<ApiCoverageBlock>,
8373}
8374
8375#[derive(Serialize)]
8376struct ApiSummaryPayload {
8377 files_analyzed: u64,
8378 files_skipped: u64,
8379 code_lines: u64,
8380 comment_lines: u64,
8381 blank_lines: u64,
8382 total_physical_lines: u64,
8383 functions: u64,
8384 classes: u64,
8385 variables: u64,
8386 imports: u64,
8387}
8388
8389#[derive(Serialize)]
8390struct ApiLanguageRow {
8391 name: String,
8392 files: u64,
8393 code_lines: u64,
8394 comment_lines: u64,
8395 blank_lines: u64,
8396 functions: u64,
8397 classes: u64,
8398 variables: u64,
8399 imports: u64,
8400}
8401
8402async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8403 let entry = {
8404 let reg = state.registry.lock().await;
8405 reg.entries.first().cloned()
8406 };
8407 entry.map_or_else(
8408 || error::not_found("no scans recorded yet"),
8409 |e| build_metrics_response(&e),
8410 )
8411}
8412
8413async fn api_metrics_run_handler(
8414 State(state): State<AppState>,
8415 AxumPath(run_id): AxumPath<String>,
8416) -> Response {
8417 let entry = {
8418 let reg = state.registry.lock().await;
8419 reg.find_by_run_id(&run_id).cloned()
8420 };
8421 entry.map_or_else(
8422 || error::not_found("run not found"),
8423 |e| build_metrics_response(&e),
8424 )
8425}
8426
8427fn build_metrics_response(entry: &RegistryEntry) -> Response {
8428 let languages: Vec<ApiLanguageRow> = entry
8429 .json_path
8430 .as_ref()
8431 .and_then(|p| read_json(p).ok())
8432 .map(|run| {
8433 run.totals_by_language
8434 .iter()
8435 .map(|l| ApiLanguageRow {
8436 name: l.language.display_name().to_string(),
8437 files: l.files,
8438 code_lines: l.code_lines,
8439 comment_lines: l.comment_lines,
8440 blank_lines: l.blank_lines,
8441 functions: l.functions,
8442 classes: l.classes,
8443 variables: l.variables,
8444 imports: l.imports,
8445 })
8446 .collect()
8447 })
8448 .unwrap_or_default();
8449
8450 let s = &entry.summary;
8451 let coverage = if s.coverage_lines_found > 0 {
8452 let pct = |hit: u64, found: u64| -> f64 {
8453 if found == 0 {
8454 0.0
8455 } else {
8456 #[allow(clippy::cast_precision_loss)]
8457 let v = (hit as f64 / found as f64) * 100.0;
8458 (v * 10.0).round() / 10.0
8459 }
8460 };
8461 Some(ApiCoverageBlock {
8462 lines_found: s.coverage_lines_found,
8463 lines_hit: s.coverage_lines_hit,
8464 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8465 functions_found: s.coverage_functions_found,
8466 functions_hit: s.coverage_functions_hit,
8467 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8468 branches_found: s.coverage_branches_found,
8469 branches_hit: s.coverage_branches_hit,
8470 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8471 })
8472 } else {
8473 None
8474 };
8475 Json(ApiMetricsResponse {
8476 run_id: entry.run_id.clone(),
8477 timestamp: entry.timestamp_utc.to_rfc3339(),
8478 project: entry.project_label.clone(),
8479 summary: ApiSummaryPayload {
8480 files_analyzed: s.files_analyzed,
8481 files_skipped: s.files_skipped,
8482 code_lines: s.code_lines,
8483 comment_lines: s.comment_lines,
8484 blank_lines: s.blank_lines,
8485 total_physical_lines: s.total_physical_lines,
8486 functions: s.functions,
8487 classes: s.classes,
8488 variables: s.variables,
8489 imports: s.imports,
8490 },
8491 languages,
8492 coverage,
8493 })
8494 .into_response()
8495}
8496
8497#[derive(Deserialize)]
8504struct ProjectHistoryQuery {
8505 path: Option<String>,
8506}
8507
8508#[derive(Serialize)]
8509struct ProjectHistoryResponse {
8510 scan_count: usize,
8511 last_scan_id: Option<String>,
8512 last_scan_timestamp: Option<String>,
8513 last_scan_code_lines: Option<u64>,
8514 last_git_branch: Option<String>,
8515 last_git_commit: Option<String>,
8516}
8517
8518fn entry_matches_project(
8521 entry: &RegistryEntry,
8522 root_str: &str,
8523 upload_root: &str,
8524 upload_name_suffix: Option<&str>,
8525) -> bool {
8526 if entry.input_roots.iter().any(|r| r == root_str) {
8527 return true;
8528 }
8529 if let Some(suffix) = upload_name_suffix {
8530 return entry
8531 .input_roots
8532 .iter()
8533 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8534 }
8535 false
8536}
8537
8538async fn project_history_handler(
8539 State(state): State<AppState>,
8540 Query(query): Query<ProjectHistoryQuery>,
8541) -> Response {
8542 let path = query.path.unwrap_or_default();
8543 let resolved = resolve_input_path(&path);
8544 let root_str = resolved.to_string_lossy().replace('\\', "/");
8545
8546 let upload_root = std::env::temp_dir()
8551 .join("oxide-sloc-uploads")
8552 .to_string_lossy()
8553 .replace('\\', "/");
8554 let upload_name_suffix: Option<String> =
8555 if state.server_mode && root_str.starts_with(&upload_root) {
8556 resolved
8557 .file_name()
8558 .and_then(|n| n.to_str())
8559 .map(|name| format!("/{name}"))
8560 } else {
8561 None
8562 };
8563 let suffix_ref = upload_name_suffix.as_deref();
8564
8565 let entries: Vec<_> = {
8566 let reg = state.registry.lock().await;
8567 reg.entries
8568 .iter()
8569 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8570 .cloned()
8571 .collect()
8572 };
8573 let scan_count = entries.len();
8574 let last = entries.first();
8575 let last_scan_id = last.map(|e| e.run_id.clone());
8576 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8577 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8578 let last_git_branch = last.and_then(|e| e.git_branch.clone());
8579 let last_git_commit = last.and_then(|e| e.git_commit.clone());
8580
8581 Json(ProjectHistoryResponse {
8582 scan_count,
8583 last_scan_id,
8584 last_scan_timestamp,
8585 last_scan_code_lines,
8586 last_git_branch,
8587 last_git_commit,
8588 })
8589 .into_response()
8590}
8591
8592#[derive(Deserialize)]
8599struct MetricsHistoryQuery {
8600 root: Option<String>,
8601 limit: Option<usize>,
8602 submodule: Option<String>,
8605}
8606
8607#[derive(Serialize)]
8608struct MetricsSubmoduleLink {
8609 name: String,
8610 url: String,
8611}
8612
8613#[derive(Serialize)]
8614struct MetricsHistoryEntry {
8615 run_id: String,
8616 run_id_short: String,
8617 timestamp: String,
8618 commit: Option<String>,
8619 branch: Option<String>,
8620 tags: Vec<String>,
8621 nearest_tag: Option<String>,
8622 code_lines: u64,
8623 comment_lines: u64,
8624 blank_lines: u64,
8625 physical_lines: u64,
8626 files_analyzed: u64,
8627 files_skipped: u64,
8628 test_count: u64,
8629 project_label: String,
8630 html_url: Option<String>,
8631 has_pdf: bool,
8632 submodule_links: Vec<MetricsSubmoduleLink>,
8633 #[serde(skip_serializing_if = "Option::is_none")]
8635 coverage_line_pct: Option<f64>,
8636}
8637
8638fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8639 let mut links: Vec<MetricsSubmoduleLink> = vec![];
8640 let sub_dir = e
8641 .html_path
8642 .as_ref()
8643 .and_then(|p| p.parent())
8644 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8645 let Some(dir) = sub_dir else { return links };
8646 let Ok(rd) = std::fs::read_dir(dir) else {
8647 return links;
8648 };
8649 for entry_res in rd.flatten() {
8650 let fname = entry_res.file_name();
8651 let fname_str = fname.to_string_lossy();
8652 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8653 let stem = &fname_str[..fname_str.len() - 5];
8654 let display = stem[4..].replace('-', " ");
8655 links.push(MetricsSubmoduleLink {
8656 name: display,
8657 url: format!("/runs/{stem}/{}", e.run_id),
8658 });
8659 }
8660 }
8661 links.sort_by(|a, b| a.name.cmp(&b.name));
8662 links
8663}
8664
8665fn apply_submodule_filter(
8666 base: MetricsHistoryEntry,
8667 filter: &str,
8668 e: &sloc_core::history::RegistryEntry,
8669) -> Option<MetricsHistoryEntry> {
8670 let json_path = e.json_path.as_ref()?;
8671 let json_str = std::fs::read_to_string(json_path).ok()?;
8672 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8673 let sub = run
8674 .submodule_summaries
8675 .iter()
8676 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8677 let safe = sanitize_project_label(&sub.name);
8678 let artifact_key = format!("sub_{safe}");
8679 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8680 || base.html_url.clone(),
8681 |run_dir| {
8682 let sub_path = run_dir.join(format!("{artifact_key}.html"));
8683 if sub_path.exists() {
8684 Some(format!("/runs/{artifact_key}/{}", e.run_id))
8685 } else {
8686 base.html_url.clone()
8687 }
8688 },
8689 );
8690
8691 let sub_files: Vec<_> = run
8694 .per_file_records
8695 .iter()
8696 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8697 .collect();
8698 let test_count: u64 = sub_files
8699 .iter()
8700 .map(|r| r.raw_line_categories.test_count)
8701 .sum();
8702 #[allow(clippy::cast_precision_loss)]
8703 let coverage_line_pct: Option<f64> = {
8704 let found: u64 = sub_files
8705 .iter()
8706 .filter_map(|r| r.coverage.as_ref())
8707 .map(|c| u64::from(c.lines_found))
8708 .sum();
8709 let hit: u64 = sub_files
8710 .iter()
8711 .filter_map(|r| r.coverage.as_ref())
8712 .map(|c| u64::from(c.lines_hit))
8713 .sum();
8714 if found > 0 {
8715 let pct = (hit as f64 / found as f64) * 100.0;
8716 Some((pct * 10.0).round() / 10.0)
8717 } else {
8718 None
8719 }
8720 };
8721
8722 Some(MetricsHistoryEntry {
8723 code_lines: sub.code_lines,
8724 comment_lines: sub.comment_lines,
8725 blank_lines: sub.blank_lines,
8726 physical_lines: sub.total_physical_lines,
8727 files_analyzed: sub.files_analyzed,
8728 files_skipped: 0,
8729 test_count,
8730 html_url: sub_html_url,
8731 has_pdf: false,
8732 submodule_links: vec![],
8733 coverage_line_pct,
8734 ..base
8735 })
8736}
8737
8738#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
8740 State(state): State<AppState>,
8741 Query(query): Query<MetricsHistoryQuery>,
8742) -> Response {
8743 let limit = query.limit.unwrap_or(50).min(500);
8744 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8745
8746 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8747 let reg = state.registry.lock().await;
8748 reg.entries
8749 .iter()
8750 .filter(|e| {
8751 query.root.as_ref().is_none_or(|root| {
8752 let resolved = resolve_input_path(root);
8753 let root_str = resolved.to_string_lossy().replace('\\', "/");
8754 e.input_roots.iter().any(|r| r == &root_str)
8755 })
8756 })
8757 .take(limit)
8758 .cloned()
8759 .collect()
8760 };
8761
8762 let entries: Vec<MetricsHistoryEntry> = candidate_entries
8763 .into_iter()
8764 .filter_map(|e| {
8765 let tags = e
8766 .git_tags
8767 .as_deref()
8768 .map(|s| {
8769 s.split(',')
8770 .map(|t| t.trim().to_string())
8771 .filter(|t| !t.is_empty())
8772 .collect()
8773 })
8774 .unwrap_or_default();
8775 let html_url = e
8776 .html_path
8777 .as_ref()
8778 .filter(|p| p.exists())
8779 .map(|_| format!("/runs/html/{}", e.run_id));
8780 let nearest_tag = e.git_nearest_tag.clone();
8781 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8782 let run_id_short: String = e
8783 .run_id
8784 .split('-')
8785 .next_back()
8786 .unwrap_or(&e.run_id)
8787 .chars()
8788 .take(7)
8789 .collect();
8790 let submodule_links = build_entry_submodule_links(&e);
8791 #[allow(clippy::cast_precision_loss)]
8792 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8793 let pct = (e.summary.coverage_lines_hit as f64
8794 / e.summary.coverage_lines_found as f64)
8795 * 100.0;
8796 Some((pct * 10.0).round() / 10.0)
8797 } else {
8798 None
8799 };
8800 let base = MetricsHistoryEntry {
8801 run_id: e.run_id.clone(),
8802 run_id_short,
8803 timestamp: e.timestamp_utc.to_rfc3339(),
8804 commit: e.git_commit.clone(),
8805 branch: e.git_branch.clone(),
8806 tags,
8807 nearest_tag,
8808 code_lines: e.summary.code_lines,
8809 comment_lines: e.summary.comment_lines,
8810 blank_lines: e.summary.blank_lines,
8811 physical_lines: e.summary.total_physical_lines,
8812 files_analyzed: e.summary.files_analyzed,
8813 files_skipped: e.summary.files_skipped,
8814 test_count: e.summary.test_count,
8815 project_label: e.project_label.clone(),
8816 html_url,
8817 has_pdf,
8818 submodule_links,
8819 coverage_line_pct,
8820 };
8821 if let Some(ref filter) = submodule_filter {
8822 apply_submodule_filter(base, filter, &e)
8823 } else {
8824 Some(base)
8825 }
8826 })
8827 .collect();
8828
8829 Json(entries).into_response()
8830}
8831
8832#[derive(Serialize)]
8834struct ChurnEntry {
8835 run_id: String,
8836 added: i64,
8837 removed: i64,
8838 modified: i64,
8839 unmodified: i64,
8840}
8841
8842async fn api_metrics_churn_handler(
8847 State(state): State<AppState>,
8848 Query(query): Query<MetricsHistoryQuery>,
8849) -> Response {
8850 let limit = query.limit.unwrap_or(200).min(500);
8851 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8852 let reg = state.registry.lock().await;
8853 reg.entries
8854 .iter()
8855 .filter(|e| {
8856 query.root.as_ref().is_none_or(|root| {
8857 let resolved = resolve_input_path(root);
8858 let root_str = resolved.to_string_lossy().replace('\\', "/");
8859 e.input_roots.iter().any(|r| r == &root_str)
8860 })
8861 })
8862 .take(limit)
8863 .cloned()
8864 .collect()
8865 };
8866 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8867 std::collections::HashMap::new();
8868 for e in candidate_entries {
8869 by_project
8870 .entry(e.project_label.clone())
8871 .or_default()
8872 .push(e);
8873 }
8874 let mut out: Vec<ChurnEntry> = Vec::new();
8875 for (_proj, mut entries) in by_project {
8876 entries.sort_by_key(|e| e.timestamp_utc);
8877 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
8878 for e in &entries {
8879 let curr = e
8880 .json_path
8881 .as_ref()
8882 .and_then(|path| sloc_core::read_json(path).ok());
8883 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
8884 let cmp = sloc_core::compute_delta(prev, cur);
8885 out.push(ChurnEntry {
8886 run_id: e.run_id.clone(),
8887 added: sum_added_code_lines(&cmp),
8888 removed: sum_removed_code_lines(&cmp),
8889 modified: sum_modified_code_lines(&cmp),
8890 unmodified: sum_unmodified_code_lines(&cmp),
8891 });
8892 } else {
8893 out.push(ChurnEntry {
8894 run_id: e.run_id.clone(),
8895 added: 0,
8896 removed: 0,
8897 modified: 0,
8898 unmodified: 0,
8899 });
8900 }
8901 if curr.is_some() {
8902 prev_run = curr;
8903 }
8904 }
8905 }
8906 Json(out).into_response()
8907}
8908
8909#[derive(Deserialize)]
8913struct MetricsSubmodulesQuery {
8914 root: Option<String>,
8915}
8916
8917#[derive(Serialize)]
8918struct SubmoduleEntry {
8919 name: String,
8920 relative_path: String,
8921}
8922
8923async fn api_metrics_submodules_handler(
8924 State(state): State<AppState>,
8925 Query(query): Query<MetricsSubmodulesQuery>,
8926) -> Response {
8927 let json_paths: Vec<std::path::PathBuf> = {
8928 let reg = state.registry.lock().await;
8929 reg.entries
8930 .iter()
8931 .filter(|e| {
8932 query.root.as_ref().is_none_or(|root| {
8933 let resolved = resolve_input_path(root);
8934 let root_str = resolved.to_string_lossy().replace('\\', "/");
8935 e.input_roots.iter().any(|r| r == &root_str)
8936 })
8937 })
8938 .filter_map(|e| e.json_path.clone())
8939 .collect()
8940 };
8941
8942 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8943 let mut result: Vec<SubmoduleEntry> = Vec::new();
8944
8945 for path in &json_paths {
8946 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8947 continue;
8948 };
8949 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8950 continue;
8951 };
8952 for sub in &run.submodule_summaries {
8953 if seen.insert(sub.name.clone()) {
8954 result.push(SubmoduleEntry {
8955 name: sub.name.clone(),
8956 relative_path: sub.relative_path.clone(),
8957 });
8958 }
8959 }
8960 }
8961
8962 result.sort_by(|a, b| a.name.cmp(&b.name));
8963 Json(result).into_response()
8964}
8965
8966#[derive(Deserialize)]
8975struct IngestQuery {
8976 label: Option<String>,
8977}
8978
8979#[derive(Serialize)]
8980struct IngestResponse {
8981 run_id: String,
8982 view_url: String,
8983}
8984
8985async fn api_ingest_handler(
8986 State(state): State<AppState>,
8987 Query(q): Query<IngestQuery>,
8988 Json(run): Json<sloc_core::AnalysisRun>,
8989) -> Response {
8990 let label = q.label.unwrap_or_else(|| {
8991 run.input_roots
8992 .first()
8993 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8994 });
8995
8996 let label_for_task = label.clone();
8997 let result = tokio::task::spawn_blocking(move || {
8998 let html = render_html(&run)?;
8999 let run_id = run.tool.run_id.clone();
9000 let run_id_safe = run_id.len() <= 128
9001 && !run_id.is_empty()
9002 && run_id
9003 .chars()
9004 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9005 if !run_id_safe {
9006 anyhow::bail!(
9007 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9008 );
9009 }
9010 let project_label = sanitize_project_label(&label_for_task);
9011 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9012 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9013 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9014 _ => project_label,
9015 };
9016 let (artifacts, _pending_pdf) = persist_run_artifacts(
9017 &run,
9018 &html,
9019 &output_dir,
9020 &label_for_task,
9021 &file_stem,
9022 RunResultContext::default(),
9023 )?;
9024 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9025 })
9026 .await;
9027
9028 match result {
9029 Ok(Ok((run_id, artifacts, run))) => {
9030 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9031 (
9032 StatusCode::CREATED,
9033 Json(IngestResponse {
9034 view_url: format!("/view-reports?run_id={run_id}"),
9035 run_id,
9036 }),
9037 )
9038 .into_response()
9039 }
9040 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9041 Err(e) => error::internal(&format!("{e}")),
9042 }
9043}
9044
9045fn html_escape(s: &str) -> String {
9049 s.replace('&', "&")
9050 .replace('<', "<")
9051 .replace('>', ">")
9052 .replace('"', """)
9053}
9054
9055#[allow(clippy::cast_precision_loss)]
9056fn fmt_num(n: i64) -> String {
9057 let a = n.unsigned_abs();
9058 if a >= 1_000_000 {
9059 let v = n as f64 / 1_000_000.0;
9060 let s = format!("{v:.1}");
9061 format!("{}M", s.trim_end_matches(".0"))
9062 } else if a >= 10_000 {
9063 let v = n as f64 / 1_000.0;
9064 let s = format!("{v:.1}");
9065 format!("{}K", s.trim_end_matches(".0"))
9066 } else {
9067 let sign = if n < 0 { "-" } else { "" };
9068 if a < 1_000 {
9069 return format!("{sign}{a}");
9070 }
9071 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9072 }
9073}
9074
9075fn fmt_comma(n: i64) -> String {
9076 let sign = if n < 0 { "-" } else { "" };
9077 let a = n.unsigned_abs();
9078 if a < 1_000 {
9079 return format!("{sign}{a}");
9080 }
9081 let s = a.to_string();
9082 let bytes = s.as_bytes();
9083 let len = bytes.len();
9084 let mut out = String::with_capacity(len + len / 3);
9085 for (i, &b) in bytes.iter().enumerate() {
9086 if i > 0 && (len - i).is_multiple_of(3) {
9087 out.push(',');
9088 }
9089 out.push(b as char);
9090 }
9091 format!("{sign}{out}")
9092}
9093
9094fn group_thousands(s: &str) -> String {
9101 let (sign, rest) = match s.as_bytes().first() {
9102 Some(b'-') => ("-", &s[1..]),
9103 Some(b'+') => ("+", &s[1..]),
9104 _ => ("", s),
9105 };
9106 let (int_part, frac_part) = match rest.split_once('.') {
9107 Some((i, f)) => (i, Some(f)),
9108 None => (rest, None),
9109 };
9110 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9111 return s.to_string();
9112 }
9113 let bytes = int_part.as_bytes();
9114 let len = bytes.len();
9115 let mut grouped = String::with_capacity(len + len / 3);
9116 for (i, &b) in bytes.iter().enumerate() {
9117 if i > 0 && (len - i).is_multiple_of(3) {
9118 grouped.push(',');
9119 }
9120 grouped.push(b as char);
9121 }
9122 frac_part.map_or_else(
9123 || format!("{sign}{grouped}"),
9124 |f| format!("{sign}{grouped}.{f}"),
9125 )
9126}
9127
9128mod filters {
9130 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9133 use askama::{Result, Values};
9134
9135 #[askama::filter_fn]
9140 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9141 Ok(super::group_thousands(&value.to_string()))
9142 }
9143}
9144
9145#[derive(Deserialize, Default)]
9146struct MultiCompareQuery {
9147 runs: Option<String>,
9148 scope: Option<String>,
9150 sub: Option<String>,
9152}
9153
9154#[allow(clippy::too_many_lines)]
9155async fn multi_compare_handler(
9156 State(state): State<AppState>,
9157 Query(params): Query<MultiCompareQuery>,
9158 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9159) -> impl IntoResponse {
9160 let run_ids: Vec<String> = params
9161 .runs
9162 .as_deref()
9163 .unwrap_or("")
9164 .split(',')
9165 .map(|s| s.trim().to_string())
9166 .filter(|s| !s.is_empty())
9167 .collect();
9168
9169 if run_ids.len() < 2 {
9170 return Html(
9171 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9172 <a href=\"/compare-scans\">Go back</a></p>",
9173 )
9174 .into_response();
9175 }
9176 if run_ids.len() > 20 {
9177 return Html(
9178 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9179 at once. <a href=\"/compare-scans\">Go back</a></p>",
9180 )
9181 .into_response();
9182 }
9183
9184 let entries: Vec<Option<RegistryEntry>> = {
9186 let reg = state.registry.lock().await;
9187 run_ids
9188 .iter()
9189 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9190 .collect()
9191 };
9192
9193 for (i, entry) in entries.iter().enumerate() {
9194 if entry.is_none() {
9195 let html = format!(
9196 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9197 found. <a href=\"/compare-scans\">Go back</a></p>",
9198 run_ids[i]
9199 );
9200 return Html(html).into_response();
9201 }
9202 }
9203
9204 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9205
9206 for entry in &entries {
9207 if entry.json_path.is_none() {
9208 let html = format!(
9209 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9210 JSON data — re-run the analysis to enable comparison. \
9211 <a href=\"/compare-scans\">Go back</a></p>",
9212 &entry.run_id
9213 );
9214 return Html(html).into_response();
9215 }
9216 }
9217
9218 entries.sort_by_key(|e| e.timestamp_utc);
9220
9221 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9223 for entry in &entries {
9224 let path = entry.json_path.as_ref().unwrap();
9225 match read_json(path) {
9226 Ok(r) => runs.push(r),
9227 Err(e) => {
9228 let html = format!(
9229 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9230 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9231 &entry.run_id
9232 );
9233 return Html(html).into_response();
9234 }
9235 }
9236 }
9237
9238 let all_sub_names: Vec<String> = {
9240 let mut set = std::collections::BTreeSet::new();
9241 for r in &runs {
9242 for s in &r.submodule_summaries {
9243 set.insert(s.name.clone());
9244 }
9245 }
9246 set.into_iter().collect()
9247 };
9248 let has_submodule_data = !all_sub_names.is_empty();
9249 let active_submodule = params.sub.clone();
9250 let super_scope_active = params.scope.as_deref() == Some("super");
9251
9252 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9254
9255 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9256 let project_label = entries
9257 .first()
9258 .map_or("", |e| e.project_label.as_str())
9259 .to_string();
9260 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9261 let multi = compute_multi_delta(&run_refs);
9262 let html = multi_compare_page(
9263 &multi,
9264 &project_label,
9265 env!("CARGO_PKG_VERSION"),
9266 &csp_nonce,
9267 has_submodule_data,
9268 &all_sub_names,
9269 &runs_csv,
9270 super_scope_active,
9271 active_submodule.as_deref(),
9272 &entries,
9273 );
9274 (
9277 [(axum::http::header::CACHE_CONTROL, "no-store")],
9278 Html(html),
9279 )
9280 .into_response()
9281}
9282
9283const fn multi_delta_class(n: i64) -> &'static str {
9284 match n {
9285 1.. => "pos",
9286 ..=-1 => "neg",
9287 0 => "zero",
9288 }
9289}
9290
9291fn multi_fmt_delta(n: i64) -> String {
9292 if n > 0 {
9293 format!("+{n}")
9294 } else {
9295 format!("{n}")
9296 }
9297}
9298
9299fn js_escape(s: &str) -> String {
9301 use std::fmt::Write as _;
9302 let mut out = String::with_capacity(s.len() + 2);
9303 for c in s.chars() {
9304 match c {
9305 '"' => out.push_str("\\\""),
9306 '\\' => out.push_str("\\\\"),
9307 '\n' => out.push_str("\\n"),
9308 '\r' => out.push_str("\\r"),
9309 '\t' => out.push_str("\\t"),
9310 c if (c as u32) < 0x20 => {
9311 let _ = write!(out, "\\u{:04x}", c as u32);
9312 }
9313 c => out.push(c),
9314 }
9315 }
9316 out
9317}
9318
9319fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9321 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9322 return (
9323 "—".to_string(),
9324 "<span class=\"mc-row-val\">—</span>".to_string(),
9325 );
9326 };
9327 let cd = entry
9328 .git_commit_date
9329 .as_deref()
9330 .and_then(fmt_git_date)
9331 .unwrap_or_else(|| "—".to_string());
9332 let au = entry.git_author.as_deref().map_or_else(
9333 || "<span class=\"mc-row-val\">—</span>".to_string(),
9334 |a| {
9335 format!(
9336 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9337 <span class=\"cmp-author-handle\"></span></span>",
9338 html_escape(a)
9339 )
9340 },
9341 );
9342 (cd, au)
9343}
9344
9345fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9347 active_sub.map_or_else(
9348 || {
9349 if super_scope_active {
9350 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9351 } else {
9352 "<span class=\"mc-scope-tag mc-scope-full\">\
9353 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9354 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9355 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9356 <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>\
9357 </svg> Full scan</span>"
9358 .to_string()
9359 }
9360 },
9361 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9362 )
9363}
9364
9365fn build_mc_scan_strip(
9367 multi: &MultiScanComparison,
9368 entries: &[RegistryEntry],
9369 n: usize,
9370 is_many: bool,
9371 active_sub: Option<&str>,
9372 super_scope_active: bool,
9373 project_label: &str,
9374) -> String {
9375 use std::fmt::Write as _;
9376 let mut scan_strip = String::new();
9377 for (i, pt) in multi.points.iter().enumerate() {
9378 let ts_ms = pt.timestamp.timestamp_millis();
9379 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9380 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9381 let branch = pt.git_branch.as_deref().unwrap_or("");
9382 let report_link = format!("/runs/html/{}", pt.run_id);
9383 let branch_html = if branch.is_empty() {
9384 "<span class=\"mc-row-val\">—</span>".to_string()
9385 } else {
9386 format!(
9387 "<span class=\"mc-card-branch\">{}</span>",
9388 html_escape(branch)
9389 )
9390 };
9391 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9392 let tags_html = pt
9393 .git_tags
9394 .as_deref()
9395 .filter(|t| !t.is_empty())
9396 .map(|t| {
9397 let chips = t
9398 .split(',')
9399 .filter(|s| !s.is_empty())
9400 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9401 .collect::<Vec<_>>()
9402 .join(" ");
9403 format!(
9404 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9405 <span class=\"mc-row-val\">{chips}</span></div>"
9406 )
9407 })
9408 .unwrap_or_default();
9409 let nearest = pt
9410 .git_nearest_tag
9411 .as_deref()
9412 .map(|t| format!("near {}", html_escape(t)))
9413 .unwrap_or_default();
9414 let arrow = if i < n - 1 && !is_many {
9415 "<div class='mc-arrow'>→</div>"
9416 } else {
9417 ""
9418 };
9419 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9420 let nearest_html = if nearest.is_empty() {
9421 String::new()
9422 } else {
9423 format!(
9424 "<span class=\"mc-card-nearest-wrap\">\
9425 <span class=\"mc-card-nearest\">{nearest}</span>\
9426 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9427 </span>"
9428 )
9429 };
9430 write!(
9431 scan_strip,
9432 r#"<div class="mc-card">
9433 <div class="mc-card-header">
9434 <div class="mc-card-num">Scan {num}</div>
9435 <div class="mc-card-project-col">
9436 <div class="mc-card-project">{project_label}</div>
9437 {scope_badge}
9438 </div>
9439 </div>
9440 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9441 <div class="mc-card-rows">
9442 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9443 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9444 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9445 <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>
9446 {tags_html}
9447 </div>
9448 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9449 </div>{arrow}"#,
9450 num = i + 1,
9451 commit = html_escape(commit),
9452 commit_date = commit_date_html,
9453 ts_ms = ts_ms,
9454 code = fmt_num(pt.code_lines),
9455 scope_badge = scope_badge,
9456 nearest_html = nearest_html,
9457 )
9458 .unwrap();
9459 }
9460 scan_strip
9461}
9462
9463#[allow(clippy::too_many_lines)]
9465fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9466 use std::fmt::Write as _;
9467 struct MetricRow<'a> {
9468 label: &'a str,
9469 values: Vec<i64>,
9470 seq_deltas: Vec<i64>,
9471 net_delta: i64,
9472 }
9473 let rows: Vec<MetricRow<'_>> = vec![
9474 MetricRow {
9475 label: "Code Lines",
9476 values: multi.points.iter().map(|p| p.code_lines).collect(),
9477 seq_deltas: multi
9478 .sequential_deltas
9479 .iter()
9480 .map(|d| d.summary.code_lines_delta)
9481 .collect(),
9482 net_delta: multi.total_delta.code_lines_delta,
9483 },
9484 MetricRow {
9485 label: "Files Analyzed",
9486 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9487 seq_deltas: multi
9488 .sequential_deltas
9489 .iter()
9490 .map(|d| d.summary.files_analyzed_delta)
9491 .collect(),
9492 net_delta: multi.total_delta.files_analyzed_delta,
9493 },
9494 MetricRow {
9495 label: "Comment Lines",
9496 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9497 seq_deltas: multi
9498 .sequential_deltas
9499 .iter()
9500 .map(|d| d.summary.comment_lines_delta)
9501 .collect(),
9502 net_delta: multi.total_delta.comment_lines_delta,
9503 },
9504 MetricRow {
9505 label: "Blank Lines",
9506 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9507 seq_deltas: multi
9508 .sequential_deltas
9509 .iter()
9510 .map(|d| d.summary.blank_lines_delta)
9511 .collect(),
9512 net_delta: multi.total_delta.blank_lines_delta,
9513 },
9514 MetricRow {
9515 label: "Tests",
9516 values: multi.points.iter().map(|p| p.test_count).collect(),
9517 seq_deltas: multi
9518 .points
9519 .windows(2)
9520 .map(|pts| pts[1].test_count - pts[0].test_count)
9521 .collect(),
9522 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9523 - multi.points.first().map_or(0, |f| f.test_count),
9524 },
9525 ];
9526 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9527 for i in 0..n {
9528 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9529 if i < n - 1 {
9530 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9531 }
9532 }
9533 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9534 let mut metrics_tbody = String::new();
9535 for row in &rows {
9536 metrics_tbody.push_str("<tr>");
9537 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9538 for i in 0..n {
9539 write!(
9540 metrics_tbody,
9541 "<td class='mc-val-col'>{}</td>",
9542 fmt_comma(row.values[i])
9543 )
9544 .unwrap();
9545 if i < n - 1 {
9546 let d = row.seq_deltas[i];
9547 write!(
9548 metrics_tbody,
9549 "<td class='mc-delta-col {cls}'>{val}</td>",
9550 cls = multi_delta_class(d),
9551 val = multi_fmt_delta(d)
9552 )
9553 .unwrap();
9554 }
9555 }
9556 let nd = row.net_delta;
9557 write!(
9558 metrics_tbody,
9559 "<td class='mc-net-col {cls}'>{val}</td>",
9560 cls = multi_delta_class(nd),
9561 val = multi_fmt_delta(nd)
9562 )
9563 .unwrap();
9564 metrics_tbody.push_str("</tr>");
9565 }
9566 (metrics_thead, metrics_tbody)
9567}
9568
9569fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9571 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9572 for (i, pt) in multi.points.iter().enumerate() {
9573 let commit = pt.git_commit.as_deref().unwrap_or("");
9574 let branch = pt.git_branch.as_deref().unwrap_or("");
9575 let tags = pt.git_tags.as_deref().unwrap_or("");
9576 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9577 let scanned_ms = pt.timestamp.timestamp_millis();
9578 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9579 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9580 let commit_date = entry
9581 .and_then(|e| e.git_commit_date.as_deref())
9582 .and_then(fmt_git_date)
9583 .unwrap_or_default();
9584 let author = entry
9585 .and_then(|e| e.git_author.as_deref())
9586 .unwrap_or("")
9587 .to_string();
9588 let cov = pt
9589 .coverage_line_pct
9590 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9591 parts.push(format!(
9592 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}}}"#,
9593 run_id = js_escape(&pt.run_id),
9594 commit = js_escape(commit),
9595 branch = js_escape(branch),
9596 tags = js_escape(tags),
9597 nearest = js_escape(nearest),
9598 commit_date = js_escape(&commit_date),
9599 author = js_escape(&author),
9600 scanned = js_escape(&scanned),
9601 code = pt.code_lines,
9602 comments = pt.comment_lines,
9603 blank = pt.blank_lines,
9604 files = pt.files_analyzed,
9605 tests = pt.test_count,
9606 ));
9607 }
9608 format!("[{}]", parts.join(","))
9609}
9610
9611fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9613 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9614 for row in &multi.file_matrix {
9615 let lang = row.language.as_deref().unwrap_or("");
9616 let codes: Vec<String> = row
9617 .code_per_scan
9618 .iter()
9619 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9620 .collect();
9621 let deltas: Vec<String> = row
9622 .code_delta_per_scan
9623 .iter()
9624 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9625 .collect();
9626 parts.push(format!(
9627 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9628 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9629 status = row.overall_status,
9630 codes = codes.join(","),
9631 deltas = deltas.join(","),
9632 total = row.total_code_delta,
9633 ));
9634 }
9635 format!("[{}]", parts.join(","))
9636}
9637
9638fn build_mc_file_col_headers(n: usize) -> String {
9640 use std::fmt::Write as _;
9641 let mut out = String::new();
9642 for i in 0..n {
9643 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9644 if i < n - 1 {
9645 write!(
9646 out,
9647 "<th class='file-delta-col'>Δ→{}</th>",
9648 i + 2
9649 )
9650 .unwrap();
9651 }
9652 }
9653 out
9654}
9655
9656fn build_mc_scope_bar(
9658 has_submodule_data: bool,
9659 sub_names: &[String],
9660 runs_csv: &str,
9661 active_sub: Option<&str>,
9662 super_scope_active: bool,
9663) -> String {
9664 use std::fmt::Write as _;
9665 if !has_submodule_data {
9666 return String::new();
9667 }
9668 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9669 let full_active = active_sub.is_none() && !super_scope_active;
9670 let mut bar = format!(
9671 r#"<div class="submod-scope-bar">
9672 <span class="submod-scope-label">
9673 <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>
9674 Scope:
9675 </span>
9676 <div class="submod-scope-divider"></div>
9677 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9678 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
9679 full_cls = if full_active { " active" } else { "" },
9680 super_cls = if super_scope_active { " active" } else { "" },
9681 );
9682 for s in sub_names {
9683 let is_active = active_sub == Some(s.as_str());
9684 write!(
9685 bar,
9686 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9687 cls = if is_active { " active" } else { "" },
9688 name_enc = html_escape(s),
9689 name_esc = html_escape(s),
9690 )
9691 .unwrap();
9692 }
9693 bar.push_str("\n</div>");
9694 bar
9695}
9696
9697fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9699 active_sub.map_or_else(
9700 || {
9701 if super_scope_active {
9702 "Super-repo only — ".to_string()
9703 } else {
9704 String::new()
9705 }
9706 },
9707 |s| format!("Submodule: {} — ", html_escape(s)),
9708 )
9709}
9710
9711#[allow(clippy::too_many_lines)]
9712#[allow(clippy::too_many_arguments)]
9713fn multi_compare_page(
9714 multi: &MultiScanComparison,
9715 project_label: &str,
9716 version: &str,
9717 csp_nonce: &str,
9718 has_submodule_data: bool,
9719 sub_names: &[String],
9720 runs_csv: &str,
9721 super_scope_active: bool,
9722 active_sub: Option<&str>,
9723 entries: &[RegistryEntry],
9724) -> String {
9725 let n = multi.points.len();
9726 let is_many = n > 4;
9727 let mc_strip_class = if is_many {
9728 "mc-strip mc-strip-grid"
9729 } else {
9730 "mc-strip"
9731 };
9732
9733 let scan_strip = build_mc_scan_strip(
9735 multi,
9736 entries,
9737 n,
9738 is_many,
9739 active_sub,
9740 super_scope_active,
9741 project_label,
9742 );
9743
9744 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9746
9747 let points_json = build_mc_points_json(multi, entries);
9749 let file_matrix_json = build_mc_file_matrix_json(multi);
9750
9751 let files_modified = multi
9753 .file_matrix
9754 .iter()
9755 .filter(|f| f.overall_status == "modified")
9756 .count();
9757 let files_added = multi
9758 .file_matrix
9759 .iter()
9760 .filter(|f| f.overall_status == "added")
9761 .count();
9762 let files_removed = multi
9763 .file_matrix
9764 .iter()
9765 .filter(|f| f.overall_status == "removed")
9766 .count();
9767 let files_unchanged = multi
9768 .file_matrix
9769 .iter()
9770 .filter(|f| f.overall_status == "unchanged")
9771 .count();
9772 let total_files = multi.file_matrix.len();
9773
9774 let file_col_headers = build_mc_file_col_headers(n);
9775 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9776 let scope_bar_html = build_mc_scope_bar(
9777 has_submodule_data,
9778 sub_names,
9779 runs_csv,
9780 active_sub,
9781 super_scope_active,
9782 );
9783 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9784 let toast_assets = sloc_toast_assets(csp_nonce);
9785
9786 format!(
9787 r#"<!doctype html>
9788<html lang="en">
9789<head>
9790 <meta charset="utf-8">
9791 <meta name="viewport" content="width=device-width, initial-scale=1">
9792 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9793 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9794 <style nonce="{csp_nonce}">
9795 :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;}}
9796 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9797 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9798 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;}}
9799 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9800 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9801 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9802 .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;}}
9803 @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));}}}}
9804 .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);}}
9805 .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;}}
9806 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9807 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9808 @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;}}}}
9809 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9810 .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));}}
9811 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9812 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9813 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9814 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9815 .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;}}
9816 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9817 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9818 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9819 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9820 .nav-dropdown{{position:relative;display:inline-flex;}}
9821 .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;}}
9822 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9823 .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;}}
9824 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9825 .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);}}
9826 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9827 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9828 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9829 body:not(.dark-theme) .icon-sun{{display:none;}}
9830 body.dark-theme .icon-moon{{display:none;}}
9831 .settings-modal{{position:fixed;z-index:9999;background:var(--surface);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;}}
9832 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9833 .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);}}
9834 .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;}}
9835 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9836 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9837 .settings-modal-body{{padding:14px 16px 16px;}}
9838 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9839 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9840 .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;}}
9841 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9842 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9843 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9844 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9845 .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;}}
9846 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9847 .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;}}
9848 .btn-back:hover{{background:var(--line);}}
9849 .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;}}
9850 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;}}
9851 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9852 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9853 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9854 .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;}}
9855 .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;}}
9856 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9857 .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;}}
9858 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9859 body.dark-theme .mc-card{{background:var(--surface-2);}}
9860 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9861 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9862 .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%;}}
9863 .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;}}
9864 .mc-card-commit:hover{{color:var(--oxide);}}
9865 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9866 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9867 .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;}}
9868 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9869 .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;}}
9870 .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;}}
9871 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9872 .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;}}
9873 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
9874 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
9875 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
9876 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
9877 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
9878 .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);}}
9879 .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);}}
9880 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
9881 .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;}}
9882 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
9883 .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;}}
9884 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
9885 .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;}}
9886 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
9887 .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;}}
9888 .submod-scope-btn:hover{{background:var(--line);}}
9889 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9890 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
9891 .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;}}
9892 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
9893 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9894 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
9895 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
9896 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
9897 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
9898 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
9899 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
9900 .metrics-table .pos{{color:var(--pos);}}
9901 .metrics-table .neg{{color:var(--neg);}}
9902 .metrics-table .zero{{color:var(--muted);}}
9903 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
9904 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9905 .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;}}
9906 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9907 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
9908 .chart-wrap{{width:100%;overflow-x:auto;}}
9909 #mc-chart{{display:block;width:100%;}}
9910 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
9911 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
9912 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
9913 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
9914 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9915 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
9916 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
9917 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
9918 .ic-card-h2-row .ic-card-h2{{margin:0;}}
9919 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9920 .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;}}
9921 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9922 .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;}}
9923 .ic-svg-modal-ov.open{{display:flex;}}
9924 .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);}}
9925 .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);}}
9926 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
9927 .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;}}
9928 .ic-svg-modal-close:hover{{background:var(--line);}}
9929 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
9930 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
9931 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
9932 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
9933 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
9934 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
9935 #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;}}
9936 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9937 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
9938 .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;}}
9939 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9940 .tab-btn:hover:not(.active){{background:var(--line);}}
9941 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
9942 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
9943 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
9944 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
9945 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
9946 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
9947 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
9948 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
9949 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
9950 .table-wrap{{width:100%;overflow-x:auto;}}
9951 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
9952 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
9953 #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;}}
9954 #file-table th.left,#file-table td.left{{text-align:left;}}
9955 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
9956 .file-delta-col{{color:var(--muted);font-size:11px;}}
9957 .file-net-col{{font-weight:800;}}
9958 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
9959 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
9960 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
9961 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
9962 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
9963 .status-badge.modified{{background:#fff2d8;color:#926000;}}
9964 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
9965 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
9966 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
9967 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
9968 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
9969 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
9970 tr.row-added td{{background:rgba(26,143,71,0.04);}}
9971 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9972 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9973 tr.row-unchanged td{{color:var(--muted);}}
9974 tr.row-unchanged .status-badge{{opacity:.65;}}
9975 .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;}}
9976 .absent{{color:var(--muted);font-style:italic;}}
9977 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9978 .pagination-info{{font-size:12px;color:var(--muted);}}
9979 .pagination-btns{{display:flex;gap:5px;}}
9980 .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;}}
9981 .pg-btn:hover:not(:disabled){{background:var(--line);}}
9982 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9983 .pg-btn:disabled{{opacity:.35;cursor:default;}}
9984 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;}}
9985 .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;}}
9986 .export-btn:hover{{background:var(--line);}}
9987 .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;}}
9988 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9989 .site-footer a{{color:var(--muted);}}
9990 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;}}
9991 body.pdf-mode{{background:#fff!important;}}
9992 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
9993 .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;}}
9994 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9995 .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;}}
9996 .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;}}
9997 .mc-modal-title{{font-size:18px;font-weight:800;}}
9998 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9999 .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;}}
10000 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10001 .mc-modal-body{{padding:18px 22px;}}
10002 .mc-modal-sec{{margin-bottom:20px;}}
10003 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10004 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10005 .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;}}
10006 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10007 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10008 .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;}}
10009 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10010 .mc-modal-row:last-child{{border-bottom:none;}}
10011 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10012 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10013 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10014 .mc-modal-val a:hover{{text-decoration:underline;}}
10015 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10016 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10017 .mc-modal-stat[data-tip]{{cursor:help;}}
10018 #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);}}
10019 .mc-card{{cursor:pointer;}}
10020 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10021 </style>
10022</head>
10023<body>
10024 {loading_overlay}
10025 <div class="background-watermarks" aria-hidden="true">
10026 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10027 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10028 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10029 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10030 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10031 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10032 </div>
10033 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10034 <div class="top-nav">
10035 <div class="top-nav-inner">
10036 <a class="brand" href="/">
10037 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10038 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10039 </a>
10040 <div class="nav-right">
10041 <a class="nav-pill" href="/">Home</a>
10042 <div class="nav-dropdown">
10043 <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>
10044 <div class="nav-dropdown-menu">
10045 <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>
10046 </div>
10047 </div>
10048 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10049 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10050 <div class="nav-dropdown">
10051 <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>
10052 <div class="nav-dropdown-menu">
10053 <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>
10054 </div>
10055 </div>
10056 <div class="server-status-wrap" id="server-status-wrap">
10057 <div class="nav-pill server-online-pill" id="server-status-pill">
10058 <span class="status-dot" id="status-dot"></span>
10059 <span id="server-status-label">Server</span>
10060 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10061 </div>
10062 <div class="server-status-tip">
10063 OxideSLOC is running — accessible on your network.
10064 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10065 </div>
10066 </div>
10067 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10068 <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>
10069 </button>
10070 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10071 <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>
10072 <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>
10073 </button>
10074 </div>
10075 </div>
10076 </div>
10077
10078 <div class="page">
10079 <!-- Hero header -->
10080 <div class="mc-hero">
10081 <div class="mc-hero-header">
10082 <div>
10083 <div class="mc-title">Multi-Scan Timeline</div>
10084 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10085 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10086 </div>
10087 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10088 <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>
10089 <div class="export-group" id="mc-top-export-group">
10090 <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>
10091 <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>
10092 </div>
10093 </div>
10094 </div>
10095 {scope_bar_html}
10096 <!-- Scan strip -->
10097 <div class="{mc_strip_class}">{scan_strip}</div>
10098 </div>
10099
10100 <!-- Summary metrics table -->
10101 <div class="panel">
10102 <div class="panel-title">Metric Progression</div>
10103 <div class="table-wrap">
10104 <table class="metrics-table">
10105 <thead>{metrics_thead}</thead>
10106 <tbody>{metrics_tbody}</tbody>
10107 </table>
10108 </div>
10109 </div>
10110
10111 <!-- Scan Charts -->
10112 <div class="panel" id="mc-charts-panel">
10113 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10114 <div class="ic-grid">
10115 <!-- Timeline line chart — spans full width -->
10116 <div class="ic-card" style="grid-column:span 2">
10117 <div class="ic-card-h2-row">
10118 <span class="ic-card-h2">Timeline</span>
10119 <div class="chart-toolbar" style="margin:0">
10120 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10121 <button class="chart-metric-btn" data-metric="files">Files</button>
10122 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10123 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10124 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10125 </div>
10126 </div>
10127 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10128 </div>
10129 <!-- Code Metrics: Scan 1 vs Latest -->
10130 <div class="ic-card">
10131 <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>
10132 <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>
10133 <div id="mc-ic-c1"></div>
10134 </div>
10135 <!-- Language Code Delta -->
10136 <div class="ic-card" id="mc-ic-lang-card">
10137 <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>
10138 <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>
10139 <div id="mc-ic-c3"></div>
10140 </div>
10141 <!-- Delta by Metric -->
10142 <div class="ic-card">
10143 <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>
10144 <div id="mc-ic-c2"></div>
10145 </div>
10146 <!-- File Change Distribution -->
10147 <div class="ic-card">
10148 <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>
10149 <div id="mc-ic-c4"></div>
10150 </div>
10151 </div>
10152 </div>
10153
10154 <!-- File matrix table -->
10155 <div class="panel">
10156 <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>
10157 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10158 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10159 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10160 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10161 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10162 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10163 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10164 </div>
10165 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10166 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10167 <div class="export-group">
10168 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10169 <button type="button" class="export-btn" id="export-csv-btn">
10170 <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>
10171 CSV
10172 </button>
10173 <button type="button" class="export-btn" id="mc-file-xls-btn">
10174 <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>
10175 Excel
10176 </button>
10177 </div>
10178 </div>
10179 </div>
10180 <div class="table-wrap">
10181 <table id="file-table">
10182 <thead>
10183 <tr>
10184 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10185 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10186 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10187 {file_col_headers}
10188 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10189 </tr>
10190 </thead>
10191 <tbody id="file-tbody"></tbody>
10192 </table>
10193 </div>
10194 <div class="pagination">
10195 <span class="pagination-info" id="pg-info"></span>
10196 <div class="pagination-btns" id="pg-btns"></div>
10197 <div style="display:flex;align-items:center;gap:6px;">
10198 <span style="font-size:12px;color:var(--muted)">Show</span>
10199 <select class="per-page" id="per-page-sel">
10200 <option value="25" selected>25 per page</option>
10201 <option value="50">50 per page</option>
10202 <option value="100">100 per page</option>
10203 </select>
10204 </div>
10205 </div>
10206 </div>
10207 </div>
10208
10209 <div id="mc-ic-tt"></div>
10210
10211 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10212 <div class="ic-svg-modal">
10213 <div class="ic-svg-modal-hdr">
10214 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10215 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10216 </div>
10217 <div id="ic-svg-modal-body"></div>
10218 </div>
10219 </div>
10220
10221 <footer class="site-footer">
10222 oxide-sloc v{version} — local code metrics workbench ·
10223 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10224 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10225 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10226 · <a href="/api-docs" rel="noopener">REST API</a>
10227 </footer>
10228
10229 <script nonce="{csp_nonce}">
10230 (function(){{
10231 // ── Dark theme ───────────────────────────────────────────────────────────
10232 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10233 var renderInlineCharts=null;
10234 var tt=document.getElementById('theme-toggle');
10235 if(tt)tt.addEventListener('click',function(){{
10236 var on=document.body.classList.toggle('dark-theme');
10237 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10238 renderChart(activeMetric);
10239 if(renderInlineCharts)renderInlineCharts();
10240 }});
10241
10242 // ── Code particles ───────────────────────────────────────────────────────
10243 var container=document.getElementById('code-particles');
10244 if(container){{
10245 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()'];
10246 for(var i=0;i<28;i++){{
10247 (function(idx){{
10248 var el=document.createElement('span');el.className='code-particle';
10249 el.textContent=snips[idx%snips.length];
10250 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10251 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10252 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10253 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10254 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10255 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10256 container.appendChild(el);
10257 }})(i);
10258 }}
10259 }}
10260
10261 // ── Watermarks ───────────────────────────────────────────────────────────
10262 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10263 if(wms.length){{
10264 var placed=[];
10265 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;}}
10266 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];}}
10267 var half=Math.floor(wms.length/2);
10268 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;}});
10269 }}
10270
10271 // ── Settings / colour scheme modal ───────────────────────────────────────
10272 (function(){{
10273 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'}}];
10274 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);}});}}
10275 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10276 function init(){{
10277 var btn=document.getElementById('settings-btn');if(!btn)return;
10278 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10279 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>';
10280 document.body.appendChild(m);
10281 var g=document.getElementById('scheme-grid');
10282 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);}});
10283 var cl=document.getElementById('settings-close-btn');
10284 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');}});
10285 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10286 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10287 }}
10288 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10289 }})();
10290
10291 // ── Timezone support for scan timestamps ─────────────────────────────────
10292 (function(){{
10293 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';}};
10294 window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10295 window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('.mc-ts-local[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10296 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10297 window.applyTz(storedTz);
10298 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
10299 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10300 }})();
10301
10302 // ── Data ────────────────────────────────────────────────────────────────
10303 var POINTS={points_json};
10304 var FILES={file_matrix_json};
10305 var N={n};
10306
10307 // ── fmt helper ───────────────────────────────────────────────────────────
10308 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();}}
10309 function fmtFull(n){{return Number(n).toLocaleString();}}
10310 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10311
10312 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10313 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10314 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));}}
10315 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10316 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10317
10318 // ── Timeline chart ───────────────────────────────────────────────────────
10319 var activeMetric='code';
10320 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10321 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10322
10323 function renderChart(metric){{
10324 var svg=document.getElementById('mc-chart');if(!svg)return;
10325 var W=svg.getBoundingClientRect().width||800,H=280;
10326 svg.setAttribute('height',H);
10327 var pad={{l:62,r:20,t:32,b:72}};
10328 var dark=document.body.classList.contains('dark-theme');
10329 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10330 var valid=pts.filter(function(v){{return v!=null;}});
10331 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;}}
10332 var minV=0,maxV=Math.max.apply(null,valid);
10333 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10334 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10335 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10336 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10337 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10338 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10339 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10340 var parts=[];
10341 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10342 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>');}}
10343 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10344 var lineD='';var firstPt=true;
10345 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);}}}}
10346 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10347 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10348 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10349 for(var i=0;i<N;i++){{
10350 if(pts[i]==null)continue;
10351 var cx=xOf(i),cy=yOf(pts[i]);
10352 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10353 var hasTag=p.tags&&p.tags.length>0;
10354 // Permanent Y-value label above the dot
10355 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>');
10356 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+'"/>');
10357 var xanchor=i===0?'start':i===N-1?'end':'middle';
10358 // X-axis label at 2× the original size (18 px)
10359 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>');
10360 }}
10361 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10362 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10363 svg.innerHTML=parts.join('');
10364 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');}});
10365 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10366 svg.onmousemove=function(e){{
10367 var rect=svg.getBoundingClientRect();
10368 var scaleX=W/rect.width;
10369 var mouseX=(e.clientX-rect.left)*scaleX;
10370 var nearest=-1,minDist=Infinity;
10371 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;}}}}
10372 if(nearest<0)return;
10373 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10374 var xhair=svg.querySelector('.mc-xhair');
10375 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10376 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"/>';
10377 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10378 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10379 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>';
10380 var bx=rect.left+(nc/W*rect.width)+18;
10381 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10382 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10383 }};
10384 svg.onmouseleave=function(){{
10385 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10386 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10387 }};
10388 }}
10389
10390 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10391
10392 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10393 btn.addEventListener('click',function(){{
10394 activeMetric=this.dataset.metric;
10395 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10396 this.classList.add('active');
10397 renderChart(activeMetric);
10398 }});
10399 }});
10400 if(typeof ResizeObserver!=='undefined'){{
10401 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10402 }}
10403 renderChart(activeMetric);
10404
10405 // ── File matrix table ────────────────────────────────────────────────────
10406 var activeStatus='';
10407 var currentPage=1;
10408 var perPage=25;
10409 var mcSortCol=null,mcSortAsc=true;
10410
10411 function getFiltered(){{
10412 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10413 if(!mcSortCol)return data;
10414 var asc=mcSortAsc;
10415 return data.slice().sort(function(a,b){{
10416 var va,vb;
10417 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10418 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10419 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10420 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10421 else{{return 0;}}
10422 if(asc)return va<vb?-1:va>vb?1:0;
10423 return va<vb?1:va>vb?-1:0;
10424 }});
10425 }}
10426
10427 function renderFilePage(){{
10428 var filtered=getFiltered();
10429 var total=filtered.length;
10430 var totalPages=Math.max(1,Math.ceil(total/perPage));
10431 if(currentPage>totalPages)currentPage=totalPages;
10432 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10433 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10434 var rows=[];
10435 for(var i=start;i<end;i++){{
10436 var f=filtered[i];
10437 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10438 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10439 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10440 for(var j=0;j<N;j++){{
10441 var cv=f.c[j];
10442 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10443 if(j<N-1){{
10444 var dv=f.d[j+1];
10445 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10446 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10447 }}
10448 }}
10449 var tc=f.t;
10450 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10451 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10452 }}
10453 tbody.innerHTML=rows.join('');
10454
10455 var info=document.getElementById('pg-info');
10456 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10457 renderPgBtns(totalPages);
10458 }}
10459
10460 function renderPgBtns(totalPages){{
10461 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10462 var btns=[];
10463 function mkBtn(label,page,active,disabled){{
10464 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10465 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10466 }}
10467 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10468 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10469 if(s>1)btns.push(mkBtn('1',1,false,false));
10470 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10471 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10472 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10473 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10474 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10475 wrap.innerHTML=btns.join('');
10476 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10477 b.addEventListener('click',function(){{
10478 var pg=parseInt(this.dataset.pg,10);
10479 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10480 }});
10481 }});
10482 }}
10483
10484 // Tab filter
10485 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10486 btn.addEventListener('click',function(){{
10487 activeStatus=this.dataset.status||'';
10488 currentPage=1;
10489 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10490 this.classList.add('active');
10491 renderFilePage();
10492 }});
10493 }});
10494
10495 // Per-page selector
10496 var ppSel=document.getElementById('per-page-sel');
10497 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10498
10499 // ── Column header sort ───────────────────────────────────────────────────
10500 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10501 th.addEventListener('click',function(){{
10502 var col=th.dataset.sortCol;
10503 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10504 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10505 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10506 }});
10507 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10508 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10509 currentPage=1;renderFilePage();
10510 }});
10511 }});
10512
10513 // Reset button also clears sort
10514 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10515 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10516 mcSortCol=null;mcSortAsc=true;
10517 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10518 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10519 }});
10520 activeStatus='';currentPage=1;
10521 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10522 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10523 renderFilePage();
10524 }});
10525
10526 renderFilePage();
10527
10528 // ── CSV export ───────────────────────────────────────────────────────────
10529 var exportBtn=document.getElementById('export-csv-btn');
10530 if(exportBtn)exportBtn.addEventListener('click',function(){{
10531 var header=['File','Language','Status'];
10532 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10533 header.push('Net Delta');
10534 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10535 var filtered=getFiltered();
10536 filtered.forEach(function(f){{
10537 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10538 for(var j=0;j<N;j++){{
10539 cols.push(f.c[j]!=null?f.c[j]:'');
10540 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10541 }}
10542 cols.push(f.t);
10543 rows.push(cols.join(','));
10544 }});
10545 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10546 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10547 a.download=mcExportName('csv');a.click();
10548 }});
10549
10550 // ── File matrix extra export buttons ─────────────────────────────────────
10551 (function(){{
10552 var resetBtn=document.getElementById('mc-file-reset-btn');
10553 if(resetBtn)resetBtn.addEventListener('click',function(){{
10554 activeStatus='';currentPage=1;
10555 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10556 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10557 renderFilePage();
10558 }});
10559
10560 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10561 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10562 function mcMakeXlsx(fname){{
10563 var filtered=getFiltered();
10564 var enc=new TextEncoder();
10565 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;}}
10566 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;}}
10567 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10568 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10569 var ss=[],si={{}};
10570 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10571 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10572 function WS(){{
10573 var R=0,buf=[];
10574 function cl(c){{return String.fromCharCode(65+c);}}
10575 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10576 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10577 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10578 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>';}}
10579 return{{sc:sc,nc:nc,row:row,xml:xml}};
10580 }}
10581 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10582 var proj=mcExportProj();
10583 // \u2500\u2500 Summary sheet \u2500\u2500
10584 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10585 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10586 r1(s1(0,proj,2));
10587 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10588 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
10589 r1('');
10590 r1(s1(0,'SCAN SUMMARY',8));
10591 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));
10592 POINTS.forEach(function(p,i){{
10593 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10594 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));
10595 }});
10596 r1('');
10597 if(POINTS.length>1){{
10598 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10599 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10600 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10601 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)));}};
10602 nr('Code Lines',pf.code,pl.code);
10603 nr('Comment Lines',pf.comments,pl.comments);
10604 nr('Files Analyzed',pf.files,pl.files);
10605 nr('Tests',pf.tests,pl.tests);
10606 r1('');
10607 }}
10608 var cMod=0,cAdd=0,cRem=0,cUnch=0;
10609 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10610 var totF=FILES.length||1;
10611 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10612 r1(s1(0,'FILE CHANGES',8));
10613 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10614 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10615 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10616 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10617 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10618 var lm={{}};
10619 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;}});
10620 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10621 if(langs.length){{
10622 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10623 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10624 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)));}});
10625 }}
10626 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10627 // \u2500\u2500 File Delta sheet \u2500\u2500
10628 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10629 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10630 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);}}
10631 hcells+=s2(hc,'Net Delta',3);
10632 r2(hcells);
10633 filtered.forEach(function(f){{
10634 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10635 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));}}}}
10636 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10637 r2(cells);
10638 }});
10639 var ncols=3+N+(N-1)+1;
10640 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10641 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>';
10642 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10643 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>',
10644 '_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>',
10645 '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>',
10646 '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>',
10647 '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>',
10648 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10649 var zparts=[],zcds=[],zoff=0,znf=0;
10650 ['[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){{
10651 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10652 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]);
10653 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);
10654 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));
10655 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10656 zoff+=entry.length;znf++;
10657 }});
10658 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10659 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]);
10660 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10661 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10662 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10663 out.set(new Uint8Array(eocd),pos);
10664 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10665 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10666 }}
10667
10668 var xlsBtn=document.getElementById('mc-file-xls-btn');
10669 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10670
10671 // File matrix HTML export — interactive: sort by column, filter by status
10672 function mcFileBuildHtml(){{
10673 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10674 var hdrs=['File','Language','Status'];
10675 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10676 hdrs.push('Net \u0394');
10677 var SI=2;
10678 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;}});
10679 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10680 var cnt={{all:allRows.length}};
10681 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10682 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10683 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10684 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10685 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10686 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10687 '.sub{{font-size:12px;color:#99aabb;}}'+
10688 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10689 '.wr{{padding:16px 20px;}}'+
10690 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10691 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10692 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10693 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10694 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10695 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10696 'thead tr{{background:#1a2035;}}'+
10697 '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;}}'+
10698 'th:hover{{background:#2a3050;}}'+
10699 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10700 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10701 'tr:nth-child(even) td{{background:#faf7f4;}}'+
10702 'tr:hover td{{background:#f5f0ea;}}'+
10703 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10704 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10705 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10706 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10707 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10708 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10709 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10710 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10711 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10712 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
10713 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
10714 '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>";}}'+
10715 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10716 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10717 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
10718 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10719 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10720 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10721 '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("")'+
10722 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10723 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10724 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10725 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10726 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10727 'sd=(sc===ci)?-sd:1;sc=ci;'+
10728 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10729 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10730 'render();';
10731 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10732 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10733 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
10734 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10735 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10736 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10737 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10738 '<script>'+inlineJs+'<\/script><\/body><\/html>';
10739 }}
10740
10741 var htmlBtn=document.getElementById('mc-file-html-btn');
10742 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10743 var h=mcFileBuildHtml();
10744 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10745 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10746 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10747 }});
10748
10749 var pdfBtn=document.getElementById('mc-file-pdf-btn');
10750 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10751 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10752 }});
10753 }})();
10754
10755 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10756 (function(){{
10757 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10758 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10759 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10760 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10761 function fmt2(n){{return Number(n).toLocaleString();}}
10762 function px(n){{return Math.round(n);}}
10763 var _tt=document.getElementById('mc-ic-tt');
10764 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10765 function addTT(el){{
10766 if(!el)return;
10767 el.addEventListener('mouseover',function(e){{
10768 var t=e.target.closest('[data-ttl]');
10769 if(t&&_tt){{
10770 var ttl=t.getAttribute('data-ttl');
10771 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10772 _tt.style.display='block';mvTT(e);
10773 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10774 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10775 }} else {{
10776 if(_tt)_tt.style.display='none';
10777 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10778 }}
10779 }});
10780 el.addEventListener('mouseleave',function(){{
10781 if(_tt)_tt.style.display='none';
10782 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10783 }});
10784 el.addEventListener('mousemove',function(e){{mvTT(e);}});
10785 }}
10786 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';}}
10787 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10788 function buildCharts(){{
10789 if(N<2)return;
10790 var cs=getComputedStyle(document.body);
10791 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10792 var textCol=cv('--text','#43342d');
10793 var mutedCol=cv('--muted','#7b675b');
10794 var gFill=cv('--muted-2','#a08777');
10795 var LGY=cv('--line','#e6d0bf');
10796 var axisCol=cv('--line-strong','#d8bfad');
10797 var surf2col=cv('--surface-2','#f4ede4');
10798 var surfCol=cv('--surface','#fff8f0');
10799 var p0=POINTS[0],pLast=POINTS[N-1];
10800 var dark=document.body.classList.contains('dark-theme');
10801 var FADE=dark?'#524238':'#e6d0bf';
10802 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10803 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;}}
10804 var c1mets=[
10805 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10806 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10807 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10808 ];
10809 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10810 // Code Metrics chart — grows to fill the height its grid row settled to (the
10811 // Language Code Delta sibling usually drives that), so it never sits short at
10812 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10813 function drawC1(){{
10814 var C1W=620,C1H=200;
10815 var c1host=document.getElementById('mc-ic-c1');
10816 var c1card=c1host?c1host.closest('.ic-card'):null;
10817 if(c1host&&c1card&&c1host.clientWidth>0){{
10818 var avW=c1host.clientWidth;
10819 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10820 var wantH=availPx*C1W/avW;
10821 if(wantH>C1H)C1H=wantH;
10822 }}
10823 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10824 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10825 for(var gi=1;gi<=4;gi++){{
10826 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10827 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10828 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10829 }}
10830 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10831 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10832 c1mets.forEach(function(m,i){{
10833 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10834 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10835 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10836 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;"/>';
10837 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>';
10838 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;"/>';
10839 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>';
10840 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>';
10841 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>';
10842 }});
10843 c1+='</svg>';
10844 return c1;
10845 }}
10846 // Chart 2: Delta by Metric (net delta first scan to last)
10847 var mets=[
10848 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10849 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10850 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10851 ];
10852 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10853 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;
10854 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10855 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10856 mets.forEach(function(m,i){{
10857 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);
10858 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>';
10859 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;"/>';
10860 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>';}}
10861 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>';}}
10862 }});
10863 c2+='</svg>';
10864 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10865 var lm={{}};
10866 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;}});
10867 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10868 function drawC3(){{
10869 if(!langs.length)return'';
10870 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10871 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;
10872 var c3host=document.getElementById('mc-ic-c3');
10873 var c3card=document.getElementById('mc-ic-lang-card');
10874 var C3H=langs.length*30+24;
10875 if(c3host&&c3card&&c3host.clientWidth>0){{
10876 var avW=c3host.clientWidth;
10877 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
10878 var wantH=availPx*C3W/avW;
10879 if(wantH>C3H)C3H=wantH;
10880 }}
10881 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
10882 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10883 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10884 langs.forEach(function(l,i){{
10885 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);
10886 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
10887 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"/>';
10888 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>';}}
10889 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>';}}
10890 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>';
10891 }});
10892 c3+='</svg>';
10893 return c3;
10894 }}
10895 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
10896 var fm=0,fa=0,fr=0,fu=0;
10897 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
10898 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;}});
10899 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
10900 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
10901 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
10902 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;
10903 if(segs.length===1){{
10904 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"/>';
10905 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
10906 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>';
10907 }} else {{
10908 segs.forEach(function(s){{
10909 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
10910 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);
10911 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);
10912 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"/>';
10913 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>';}}
10914 ang4+=sw;
10915 }});
10916 }}
10917 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
10918 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
10919 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
10920 segs.forEach(function(s,i){{
10921 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
10922 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;"/>';
10923 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>';
10924 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
10925 }});
10926 c4+='</svg>';
10927 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
10928 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
10929 // once at natural height to seed the row, then both are filled to the row the
10930 // grid settled to, so neither sits short at the top of an over-tall cell.
10931 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
10932 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
10933 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
10934 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
10935 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>';
10936 if(e1)e1.innerHTML=drawC1();
10937 }}
10938 buildCharts();
10939 renderInlineCharts=buildCharts;
10940 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
10941 (function(){{
10942 var ov=document.getElementById('ic-svg-modal-ov');
10943 var body=document.getElementById('ic-svg-modal-body');
10944 var ttl=document.getElementById('ic-svg-modal-title');
10945 var closeBtn=document.getElementById('ic-svg-modal-close');
10946 if(!ov||!body)return;
10947 function close(){{ov.classList.remove('open');body.innerHTML='';}}
10948 function open(srcId,title){{
10949 var src=document.getElementById(srcId);if(!src)return;
10950 ttl.textContent=title||'';
10951 var card=src.closest('.ic-card');
10952 var legHtml='';
10953 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
10954 body.innerHTML=legHtml+src.innerHTML;
10955 var svg=body.querySelector('svg');
10956 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
10957 addTT(body);
10958 ov.classList.add('open');
10959 }}
10960 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
10961 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
10962 }});
10963 if(closeBtn)closeBtn.addEventListener('click',close);
10964 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
10965 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
10966 }})();
10967
10968 // HTML legend hover → highlight matching SVG bars within the SAME card only
10969 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
10970 var metric=leg.getAttribute('data-highlight');
10971 var parentCard=leg.closest('.ic-card');
10972 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
10973 if(!chartEl)return;
10974 leg.addEventListener('mouseenter',function(){{
10975 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
10976 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
10977 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
10978 x.style.opacity='1';
10979 }} else {{
10980 x.style.opacity='0.28';
10981 }}
10982 }});
10983 }});
10984 leg.addEventListener('mouseleave',function(){{
10985 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10986 }});
10987 }});
10988 // Author handles
10989 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
10990
10991 // ── Export helpers ────────────────────────────────────────────────────────
10992 // Fetch one image from the server and return a data-URI Promise
10993 function mcFetchUri(path){{
10994 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
10995 return new Promise(function(res){{
10996 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
10997 }});
10998 }}).catch(function(){{return '';}});
10999 }}
11000 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11001 function mcInlineImgs(html,cb){{
11002 var paths=[],seen={{}};
11003 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11004 if(!paths.length){{cb(html);return;}}
11005 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11006 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11007 .catch(function(){{cb(html);}});
11008 }}
11009 // Capture full-page HTML with all table rows visible
11010 function mcRawHtml(pdfMode){{
11011 if(pdfMode)document.body.classList.add('pdf-mode');
11012 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11013 var html=document.documentElement.outerHTML;
11014 perPage=s;currentPage=p;renderFilePage();
11015 if(pdfMode)document.body.classList.remove('pdf-mode');
11016 return html;
11017 }}
11018
11019 // HTML export (full page with inlined images)
11020 function mcDoHtml(btn,fname){{
11021 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11022 mcInlineImgs(mcRawHtml(false),function(html){{
11023 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11024 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11025 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11026 btn.disabled=false;btn.innerHTML=orig;
11027 }});
11028 }}
11029 // PDF export — comprehensive document-style report: full numbers, all sections
11030 function mcBuildPdfHtml(){{
11031 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11032 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11033 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11034 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>';}}
11035 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11036 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11037 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)));}}
11038 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11039 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11040 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11041 // Header/footer flow in document order (NOT position:fixed) — a fixed
11042 // header repeats every printed page in Chromium and overlaps the content
11043 // below it, swallowing the first rows of pages 2+ and clipping the cards
11044 // on page 1. The table <thead> repeats per page natively, so every row
11045 // stays visible.
11046 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11047 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11048 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11049 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11050 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11051 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11052 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11053 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11054 '.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;}}'+
11055 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11056 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11057 '.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;}}'+
11058 '.body{{padding:12px 18px 0;}}'+
11059 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11060 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11061 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11062 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11063 '.sec{{margin-bottom:10px;}}'+
11064 '.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;}}'+
11065 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11066 '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;}}'+
11067 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11068 'tr:nth-child(even) td{{background:#faf8f6;}}';
11069 // ── Metric Progression ────────────────────────────────────────────────
11070 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11071 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11072 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>';
11073 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11074 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11075 var progRows=POINTS.map(function(pt,i){{
11076 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)));
11077 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11078 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11079 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11080 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11081 '<td style="text-align:right">'+full(pt.files)+'</td>';
11082 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11083 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11084 return r+'</tr>';
11085 }}).join('');
11086 // ── Scan-to-scan changes ──────────────────────────────────────────────
11087 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11088 var prev=POINTS[i];
11089 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11090 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11091 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11092 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11093 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11094 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11095 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11096 }}).join(''):'';
11097 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11098 var fmSection='';
11099 if(FILES&&FILES.length){{
11100 // Hard cap on per-scan columns so the table never overflows the page width.
11101 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11102 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11103 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11104 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11105 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11106 var fmRows=topFiles.map(function(f){{
11107 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11108 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>';
11109 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11110 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11111 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>';
11112 }}).join('');
11113 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11114 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11115 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11116 }}
11117 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11118 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11119 '<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>'+
11120
11121 '<div class="body">'+
11122 '<div class="sg">'+
11123 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11124 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11125 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11126 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11127 (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>':
11128 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11129 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11130 '</div>'+
11131 '<div class="sec"><p class="sh">Metric Progression</p>'+
11132 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11133 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11134 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11135 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11136 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11137 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11138 fmSection+
11139 '</div>'+
11140 '<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>'+
11141 '</body></html>';
11142 }}
11143 function mcDoPdf(btn){{
11144 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11145 }}
11146
11147 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11148 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11149 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11150 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11151 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11152 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11153 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11154 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11155 if(location.protocol==='file:'){{
11156 [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';}}}} );
11157 [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';}}}} );
11158 }}
11159 }})();
11160 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11161 (function(){{
11162 function $(id){{return document.getElementById(id);}}
11163 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11164 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11165 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11166 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11167 function openModal(idx){{
11168 var ov=$('mc-modal-overlay');if(!ov)return;
11169 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11170 if(idx<0||idx>=N)return;
11171 var pt=POINTS[idx];
11172 titleEl.textContent='Scan '+(idx+1);
11173 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11174 subEl.textContent=lbl;
11175 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11176 '<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>'+
11177 '<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>'+
11178 '<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>'+
11179 '<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>'+
11180 (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>':'')+
11181 (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>':'')+
11182 '</div></div>';
11183 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11184 (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>':'')+
11185 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11186 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11187 (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>':'')+
11188 (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>':'')+
11189 (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>':'')+
11190 (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>':'')+
11191 '<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>'+
11192 '</div>';
11193 var dHtml='';
11194 if(idx>0){{
11195 var prev=POINTS[idx-1];
11196 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11197 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11198 '<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>'+
11199 '<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>'+
11200 '<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>'+
11201 '</div></div>';
11202 }}
11203 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11204 ov.classList.add('open');document.body.style.overflow='hidden';
11205 }}
11206 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11207 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11208 document.addEventListener('click',function(e){{
11209 if(!e.target||!e.target.closest)return;
11210 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11211 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11212 var card=e.target.closest('.mc-card');
11213 if(!card)return;
11214 if(e.target.closest('a'))return;
11215 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11216 var i=cards.indexOf(card);
11217 if(i>=0)openModal(i);
11218 }});
11219 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11220 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11221 var statTip=null;
11222 document.addEventListener('mousemove',function(e){{
11223 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11224 if(!box){{if(statTip)statTip.style.display='none';return;}}
11225 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11226 var tip=box.getAttribute('data-tip')||'';
11227 if(statTip.textContent!==tip)statTip.textContent=tip;
11228 statTip.style.display='block';
11229 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11230 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11231 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11232 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11233 }});
11234 (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');}})();
11235 }})();
11236 }})();
11237 </script>
11238 <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]';
11239 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;}}
11240 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>
11241 <!-- Scan card detail modal -->
11242 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11243 <div class="mc-modal" id="mc-modal">
11244 <div class="mc-modal-head">
11245 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11246 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11247 </div>
11248 <div class="mc-modal-body" id="mc-modal-body"></div>
11249 </div>
11250 </div>
11251 {toast_assets}
11252</body>
11253</html>"#,
11254 project_label = html_escape(project_label),
11255 n = n,
11256 scan_strip = scan_strip,
11257 mc_strip_class = mc_strip_class,
11258 metrics_thead = metrics_thead,
11259 metrics_tbody = metrics_tbody,
11260 file_col_headers = file_col_headers,
11261 total_files = total_files,
11262 files_modified = files_modified,
11263 files_added = files_added,
11264 files_removed = files_removed,
11265 files_unchanged = files_unchanged,
11266 points_json = points_json,
11267 file_matrix_json = file_matrix_json,
11268 nav_compare_active = nav_compare_active,
11269 version = version,
11270 csp_nonce = csp_nonce,
11271 scope_bar_html = scope_bar_html,
11272 scope_label = scope_label,
11273 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11274 )
11275}
11276
11277#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11285 State(state): State<AppState>,
11286 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11287) -> Response {
11288 auto_scan_watched_dirs(&state).await;
11289
11290 let watched_dirs_list: Vec<String> = {
11291 let wd = state.watched_dirs.lock().await;
11292 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11293 };
11294
11295 let roots: Vec<String> = {
11297 let reg = state.registry.lock().await;
11298 let mut seen = std::collections::BTreeSet::new();
11299 reg.entries
11300 .iter()
11301 .flat_map(|e| e.input_roots.iter().cloned())
11302 .filter(|r| seen.insert(r.clone()))
11303 .collect()
11304 };
11305
11306 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11307 let nonce = &csp_nonce;
11308 let version = env!("CARGO_PKG_VERSION");
11309 let toast_assets = sloc_toast_assets(nonce);
11310
11311 let watched_dirs_html: String = if state.server_mode {
11315 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()
11316 } else {
11317 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11318 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11319 .to_string()
11320 } else {
11321 watched_dirs_list
11322 .iter()
11323 .fold(String::new(), |mut s, d| {
11324 use std::fmt::Write as _;
11325 let escaped =
11326 d.replace('&', "&").replace('"', """).replace('<', "<");
11327 write!(
11328 s,
11329 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>"#
11330 ).expect("write to String is infallible");
11331 s
11332 })
11333 };
11334 format!(
11335 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>"#
11336 )
11337 };
11338
11339 let html = format!(
11340 r##"<!doctype html>
11341<html lang="en">
11342<head>
11343 <meta charset="utf-8" />
11344 <meta name="viewport" content="width=device-width, initial-scale=1" />
11345 <title>OxideSLOC | Trend Reports</title>
11346 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11347 <style nonce="{nonce}">
11348 :root {{
11349 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11350 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11351 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11352 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11353 --info-bg:#eef3ff; --info-text:#4467d8;
11354 }}
11355 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11356 *{{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;}}
11357 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11358 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11359 .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;}}
11360 @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));}}}}
11361 .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);}}
11362 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11363 .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));}}
11364 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11365 .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;}}
11366 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11367 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11368 @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; }} }}
11369 .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;}}
11370 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11371 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11372 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11373 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11374 .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;}}
11375 .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;}}
11376 .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;}}
11377 .settings-modal{{position:fixed;z-index:9999;background:var(--surface);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;}}
11378 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11379 .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);}}
11380 .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;}}
11381 .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;}}
11382 .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;}}
11383 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11384 .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;}}
11385 .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);}}
11386 .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;}}
11387 .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;}}
11388 .tz-select:focus{{border-color:var(--oxide);}}
11389 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11390 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11391 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11392 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11393 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11394 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11395 .trend-title-block{{flex:1;min-width:0;}}
11396 .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;}}
11397 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11398 .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;}}
11399 .chart-select:focus{{border-color:var(--accent);}}
11400 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11401 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11402 .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);}}
11403 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11404 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11405 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11406 .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);}}
11407 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11408 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11409 .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;}}
11410 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11411 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11412 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11413 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11414 .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;}}
11415 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11416 .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;}}
11417 .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);}}
11418 .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;}}
11419 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11420 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11421 .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);}}
11422 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11423 .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;}}
11424 .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;}}
11425 .data-table tr:last-child td{{border-bottom:none;}}
11426 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11427 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11428 .table-wrap{{width:100%;overflow-x:auto;}}
11429 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11430 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11431 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11432 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11433 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11434 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11435 .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;}}
11436 .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;}}
11437 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11438 .pagination-info{{font-size:13px;color:var(--muted);}}
11439 .pagination-btns{{display:flex;gap:6px;}}
11440 .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;}}
11441 .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;}}
11442 #scan-history-table col:nth-child(1){{width:155px;}}
11443 #scan-history-table col:nth-child(2){{width:240px;}}
11444 #scan-history-table col:nth-child(3){{width:82px;}}
11445 #scan-history-table col:nth-child(4){{width:82px;}}
11446 #scan-history-table col:nth-child(5){{width:90px;}}
11447 #scan-history-table col:nth-child(6){{width:90px;}}
11448 #scan-history-table col:nth-child(7){{width:88px;}}
11449 #scan-history-table col:nth-child(8){{width:150px;}}
11450 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11451 .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;}}
11452 .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;}}
11453 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11454 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11455 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11456 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11457 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11458 .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;}}
11459 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11460 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11461 .watched-chip-rm:hover{{color:var(--oxide);}}
11462 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11463 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11464 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11465 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11466 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11467 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11468 a.run-link:hover{{text-decoration:underline;}}
11469 .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);}}
11470 .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);}}
11471 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11472 .metric-num{{font-weight:700;color:var(--text);}}
11473 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11474 .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;}}
11475 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11476 .btn.primary:hover{{opacity:.9;}}
11477 .rpt-btn{{min-width:58px;justify-content:center;}}
11478 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11479 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11480 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11481 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11482 .submod-details summary::-webkit-details-marker{{display:none;}}
11483 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11484 .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;}}
11485 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11486 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11487 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11488 .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;}}
11489 .export-btn:hover{{background:var(--line);}}
11490 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11491 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11492 .site-footer a{{color:var(--muted);}}
11493 .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;}}
11494 .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;}}
11495 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11496 /* Modal system (Retention Policy / Clean-up) */
11497 .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;}}
11498 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11499 .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);}}
11500 .tr-modal{{background:rgba(255,255,255,0.90);}}
11501 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11502 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11503 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11504 .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);}}
11505 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11506 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11507 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11508 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11509 .tr-modal-body{{padding:22px 30px;}}
11510 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11511 .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;}}
11512 .tr-btn:hover{{transform:translateY(-1px);}}
11513 .tr-btn:active{{transform:translateY(0);}}
11514 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11515 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11516 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11517 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11518 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11519 .tr-btn-secondary:hover{{background:var(--line);}}
11520 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11521 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11522 </style>
11523</head>
11524<body>
11525 <div class="background-watermarks" aria-hidden="true">
11526 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11527 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11528 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11529 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11530 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11531 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11532 </div>
11533 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11534 <div class="top-nav">
11535 <div class="top-nav-inner">
11536 <a class="brand" href="/">
11537 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11538 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11539 </a>
11540 <div class="nav-right">
11541 <a class="nav-pill" href="/">Home</a>
11542 <div class="nav-dropdown">
11543 <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>
11544 <div class="nav-dropdown-menu">
11545 <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>
11546 </div>
11547 </div>
11548 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11549 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11550 <div class="nav-dropdown">
11551 <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>
11552 <div class="nav-dropdown-menu">
11553 <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>
11554 </div>
11555 </div>
11556 <div class="server-status-wrap" id="server-status-wrap">
11557 <div class="nav-pill server-online-pill" id="server-status-pill">
11558 <span class="status-dot" id="status-dot"></span>
11559 <span id="server-status-label">Server</span>
11560 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11561 </div>
11562 <div class="server-status-tip">
11563 OxideSLOC is running — accessible on your network.
11564 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11565 </div>
11566 </div>
11567 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11568 <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>
11569 </button>
11570 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11571 <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>
11572 <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>
11573 </button>
11574 </div>
11575 </div>
11576 </div>
11577
11578 <div class="page">
11579 {watched_dirs_html}
11580 <div class="summary-strip" id="trend-stats"></div>
11581 <div class="panel">
11582 <div class="trend-header">
11583 <div class="trend-title-block">
11584 <h1>Trend Reports</h1>
11585 <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>
11586 <span class="chart-hint-inline">
11587 <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>
11588 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
11589 </span>
11590 </div>
11591 <div class="chart-actions">
11592 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11593 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11594 Retention Policy
11595 </button>
11596 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11597 <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>
11598 Clean up old runs
11599 </button>
11600 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11601 <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>
11602 Export Excel
11603 </button>
11604 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11605 <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>
11606 Export PNG
11607 </button>
11608 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11609 <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>
11610 Export PDF
11611 </button>
11612 </div>
11613 </div>
11614
11615 <div class="controls-centered">
11616 <label>Project Root:
11617 <select class="chart-select" id="root-sel">
11618 <option value="">All projects</option>
11619 </select>
11620 </label>
11621 <label>Y Metric:
11622 <select class="chart-select" id="y-sel">
11623 <option value="code_lines">Code Lines</option>
11624 <option value="comment_lines">Comment Lines</option>
11625 <option value="blank_lines">Blank Lines</option>
11626 <option value="physical_lines">Physical Lines</option>
11627 <option value="files_analyzed">Files Analyzed</option>
11628 </select>
11629 </label>
11630 <label>X Axis:
11631 <select class="chart-select" id="x-sel">
11632 <option value="time">By Time</option>
11633 <option value="commit" selected>By Commit</option>
11634 <option value="release">By Release</option>
11635 <option value="tag">Tagged Commits</option>
11636 </select>
11637 </label>
11638 <label id="submodule-label" style="display:none;">Submodule:
11639 <select class="chart-select" id="sub-sel">
11640 <option value="">All (project total)</option>
11641 </select>
11642 </label>
11643 <label>Chart Size:
11644 <select class="chart-select" id="scale-sel">
11645 <option value="0.75">Compact</option>
11646 <option value="1.2" selected>Normal</option>
11647 <option value="1.38">Large</option>
11648 </select>
11649 </label>
11650 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
11651 </div>
11652
11653 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
11654 <div id="data-table-wrap" style="overflow-x:auto;"></div>
11655 </div>
11656 </div>
11657
11658 <script nonce="{nonce}">
11659 (function() {{
11660 // Theme persistence
11661 var b = document.body;
11662 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11663 var tgl = document.getElementById('theme-toggle');
11664 if (tgl) tgl.addEventListener('click', function() {{
11665 var d = b.classList.toggle('dark-theme');
11666 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11667 }});
11668
11669 // Watermark randomizer
11670 (function() {{
11671 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11672 if (!wms.length) return;
11673 var placed = [];
11674 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;}}
11675 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];}}
11676 var half=Math.floor(wms.length/2);
11677 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;}});
11678 }})();
11679
11680 // Code particles
11681 (function() {{
11682 var container = document.getElementById('code-particles');
11683 if (!container) return;
11684 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'];
11685 for (var i = 0; i < 38; i++) {{
11686 (function(idx) {{
11687 var el = document.createElement('span');
11688 el.className = 'code-particle';
11689 el.textContent = snippets[idx % snippets.length];
11690 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11691 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11692 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11693 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';
11694 container.appendChild(el);
11695 }})(i);
11696 }}
11697 }})();
11698
11699 // Watched folder picker
11700 (function() {{
11701 var btn = document.getElementById('add-watched-btn');
11702 if (!btn) return;
11703 btn.addEventListener('click', function() {{
11704 fetch('/pick-directory?kind=reports')
11705 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11706 .then(function(data) {{
11707 if (!data.cancelled && data.selected_path) {{
11708 var form = document.createElement('form');
11709 form.method = 'POST';
11710 form.action = '/watched-dirs/add';
11711 var ri = document.createElement('input');
11712 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11713 var fi = document.createElement('input');
11714 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11715 form.appendChild(ri); form.appendChild(fi);
11716 document.body.appendChild(form);
11717 form.submit();
11718 }}
11719 }})
11720 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11721 }});
11722 }})();
11723
11724 // Settings / color-scheme modal
11725 (function() {{
11726 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'}}];
11727 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);}});}}
11728 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11729 var btn=document.getElementById('settings-btn');if(!btn)return;
11730 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11731 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>';
11732 document.body.appendChild(m);
11733 var g=document.getElementById('scheme-grid');
11734 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);}});
11735 var cl=document.getElementById('settings-close');
11736 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.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};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);}});}};var tzSel=document.getElementById('tz-select');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);
11737 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');}});
11738 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11739 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11740 }})();
11741 }})();
11742
11743 var ROOTS = {roots_json};
11744 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11745 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11746 var allData = [];
11747
11748 // Populate root selector
11749 var rootSel = document.getElementById('root-sel');
11750 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11751
11752 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();}}
11753 function fmtFull(n){{return Number(n).toLocaleString();}}
11754 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
11755
11756 // Tooltip
11757 var tt = document.createElement('div');
11758 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);';
11759 document.body.appendChild(tt);
11760 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11761 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';}}
11762 function hideTT(){{tt.style.display='none';}}
11763 window.addEventListener('blur',function(){{hideTT();}});
11764 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11765
11766 function statExact(compact, full){{
11767 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11768 }}
11769 function statVal(n){{
11770 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11771 }}
11772
11773 function updateStats(data){{
11774 var statsEl=document.getElementById('trend-stats');
11775 if(!statsEl)return;
11776 if(!data||!data.length){{statsEl.innerHTML='';return;}}
11777 var yKey=document.getElementById('y-sel').value;
11778 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11779 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11780 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11781 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11782 var absDelta=Math.abs(delta);
11783 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11784 var deltaExact=statExact(deltaCompact,deltaFull);
11785 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11786 statsEl.innerHTML=
11787 '<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>'+
11788 '<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>'+
11789 '<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>'+
11790 '<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>';
11791 }}
11792
11793 var subSel = document.getElementById('sub-sel');
11794 var subLabel = document.getElementById('submodule-label');
11795
11796 function populateSubmodules(root){{
11797 if(!subSel||!subLabel)return;
11798 while(subSel.options.length>1)subSel.remove(1);
11799 subSel.value='';
11800 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11801 fetch(url)
11802 .then(function(r){{return r.json();}})
11803 .then(function(subs){{
11804 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11805 subs.forEach(function(s){{
11806 var o=document.createElement('option');
11807 o.value=s.name;
11808 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11809 subSel.appendChild(o);
11810 }});
11811 subLabel.style.display='';
11812 }})
11813 .catch(function(){{subLabel.style.display='none';}});
11814 }}
11815
11816 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11817
11818 function loadAndRender(){{
11819 var root = rootSel.value;
11820 var sub = subSel ? subSel.value : '';
11821 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11822 document.getElementById('data-table-wrap').innerHTML='';
11823 var url = '/api/metrics/history?limit=100'
11824 + (root ? '&root='+encodeURIComponent(root) : '')
11825 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
11826 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11827 allData = data;
11828 render(data);
11829 updateStats(data);
11830 }}).catch(function(){{
11831 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>';
11832 }});
11833 }}
11834
11835 function render(data){{
11836 var yKey = document.getElementById('y-sel').value;
11837 var xMode = document.getElementById('x-sel').value;
11838
11839 // Filter for tag/release mode
11840 var pts = data;
11841 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11842
11843 // Sort oldest-first for the line chart
11844 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11845
11846 var wrap = document.getElementById('chart-wrap');
11847 if(!pts.length){{
11848 var emptyMsg = (xMode === 'tag')
11849 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11850 : 'No scan data found for the selected filters.';
11851 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11852 renderTable([]);
11853 return;
11854 }}
11855
11856 var scaleEl=document.getElementById('scale-sel');
11857 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
11858 renderTrendInto(wrap, pts, yKey, xMode, sc);
11859 renderTable(pts, yKey);
11860 }}
11861
11862 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
11863 // Shared by the inline chart and the Full View modal so both render identically.
11864 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
11865 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
11866 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
11867 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
11868 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;
11869 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
11870
11871 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11872
11873 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">';
11874 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>';
11875
11876 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
11877
11878 // Grid + Y axis ticks
11879 for(var ti=0;ti<=5;ti++){{
11880 var gy=PT+CH-Math.round(ti/5*CH);
11881 var gv=Math.round(ti/5*maxY);
11882 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
11883 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
11884 }}
11885
11886 // X axis labels (every N-th point to avoid crowding)
11887 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
11888 pts.forEach(function(d,i){{
11889 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11890 if(i%labelEvery===0||i===pts.length-1){{
11891 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)));
11892 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>';
11893 }}
11894 }});
11895
11896 // Axis label
11897 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
11898 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>';
11899 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>';
11900
11901 // Area fill + line path
11902 var pathD='';
11903 pts.forEach(function(d,i){{
11904 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11905 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11906 pathD+=(i===0?'M':'L')+x+','+y;
11907 }});
11908 if(pts.length>1){{
11909 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
11910 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
11911 }}
11912 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
11913
11914 // Data points (clickable) + permanent value labels
11915 var showLabels = pts.length <= 40;
11916 var labelEveryN = pts.length > 20 ? 2 : 1;
11917 pts.forEach(function(d,i){{
11918 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11919 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11920 var hasTags=d.tags&&d.tags.length>0;
11921 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
11922 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
11923 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+'"/>';
11924 if(showLabels && i%labelEveryN===0){{
11925 var lx=x, ly=y-r-5;
11926 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>';
11927 }}
11928 }});
11929
11930 svg+='</svg>';
11931 wrap.innerHTML=svg;
11932
11933 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
11934 function lineYAt(mx){{
11935 var n=pts.length;
11936 if(n===0)return PT+CH;
11937 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
11938 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
11939 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
11940 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
11941 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
11942 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
11943 return y0+t*(y1-y0);
11944 }}
11945
11946 // SVG-level mousemove: show the value tooltip only when the pointer is over the
11947 // gradient fill (inside the chart and at/below the line) — never in the empty
11948 // space above the line. Cursor follows the same rule.
11949 (function(){{
11950 var svgEl=wrap.querySelector('svg');
11951 if(!svgEl)return;
11952 svgEl.addEventListener('mousemove',function(e){{
11953 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
11954 var rect=svgEl.getBoundingClientRect();
11955 var scaleX=W/Math.max(rect.width,1);
11956 var scaleY=H/Math.max(rect.height,1);
11957 var mouseX=(e.clientX-rect.left)*scaleX;
11958 var mouseY=(e.clientY-rect.top)*scaleY;
11959 var ly=lineYAt(mouseX);
11960 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
11961 svgEl.style.cursor='pointer';
11962 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
11963 var d=pts[idx];
11964 var val=Number(d[yKey]);
11965 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
11966 showTT(e,
11967 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
11968 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
11969 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
11970 );
11971 }});
11972 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
11973 }})();
11974
11975 // Attach point tooltips
11976 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
11977 c.addEventListener('mouseover',function(e){{
11978 var d=pts[parseInt(this.dataset.idx)];
11979 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(''):'';
11980 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>':'';
11981 showTT(e,
11982 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
11983 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
11984 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
11985 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
11986 );
11987 this.setAttribute('r','8');
11988 }});
11989 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
11990 c.addEventListener('mousemove',moveTT);
11991 c.addEventListener('click',function(){{
11992 var d=pts[parseInt(this.dataset.idx)];
11993 if(d.html_url) window.open(d.html_url,'_blank');
11994 }});
11995 }});
11996 }}
11997
11998 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
11999 var shProjFilter='', shBranchFilter='';
12000
12001 function fmtPST(isoStr){{
12002 if(!isoStr)return'';
12003 var d=new Date(isoStr);
12004 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12005 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);}}
12006 function p(n){{return n<10?'0'+n:String(n);}}
12007 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++;}}}}
12008 var yr=d.getUTCFullYear();
12009 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12010 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12011 var isDST=d>=dstStart&&d<dstEnd;
12012 var off=isDST?-7*3600*1000:-8*3600*1000;
12013 var lbl=isDST?'PDT':'PST';
12014 var loc=new Date(d.getTime()+off);
12015 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12016 }}
12017
12018 function getShRows(){{
12019 var proj=shProjFilter.toLowerCase().trim();
12020 var branch=shBranchFilter;
12021 return shData.filter(function(d){{
12022 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12023 if(branch&&(d.branch||'')!==branch)return false;
12024 return true;
12025 }});
12026 }}
12027
12028 function renderShPage(){{
12029 var filtered=getShRows();
12030 if(shSortCol){{
12031 filtered.sort(function(a,b){{
12032 var va,vb;
12033 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12034 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12035 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12036 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12037 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12038 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12039 }});
12040 }}
12041 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12042 shPage=Math.min(shPage,totalPages);
12043 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12044 var visible=filtered.slice(start,end);
12045 var tbody=document.getElementById('sh-tbody');
12046 if(!tbody)return;
12047 tbody.innerHTML=visible.map(function(d){{
12048 var tsHtml=esc(fmtPST(d.timestamp));
12049 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>';
12050 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>';
12051 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12052 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12053 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12054 var reportCell='';
12055 if(d.html_url){{
12056 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12057 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>';}}
12058 reportCell+='</div>';
12059 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12060 if(d.submodule_links&&d.submodule_links.length){{
12061 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12062 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12063 reportCell+='</div></details>';
12064 }}
12065 return '<tr>'
12066 +'<td>'+tsHtml+'</td>'
12067 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12068 +'<td>'+runIdHtml+'</td>'
12069 +'<td>'+commitHtml+'</td>'
12070 +'<td>'+branchHtml+'</td>'
12071 +'<td>'+tags+'</td>'
12072 +'<td class="num">'+metricHtml+'</td>'
12073 +'<td class="report-cell">'+reportCell+'</td>'
12074 +'</tr>';
12075 }}).join('');
12076 var pgRange=document.getElementById('sh-pg-range');
12077 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12078 var pgInfo=document.getElementById('sh-pg-info');
12079 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12080 var pgBtns=document.getElementById('sh-pg-btns');
12081 if(pgBtns){{
12082 pgBtns.innerHTML='';
12083 function mkPgBtn(lbl,pg,active,disabled){{
12084 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12085 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12086 return b;
12087 }}
12088 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12089 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12090 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12091 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12092 }}
12093 }}
12094
12095 function wireTableBehavior(){{
12096 var pf=document.getElementById('sh-proj-filter');
12097 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12098 var bf=document.getElementById('sh-branch-filter');
12099 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12100 var rb=document.getElementById('sh-reset-btn');
12101 if(rb)rb.addEventListener('click',function(){{
12102 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12103 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12104 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12105 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');}});
12106 renderShPage();
12107 }});
12108 var pps=document.getElementById('sh-per-page');
12109 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12110 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12111 ths.forEach(function(th){{
12112 th.addEventListener('click',function(e){{
12113 if(e.target.classList.contains('col-resize-handle'))return;
12114 var col=th.dataset.col;
12115 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12116 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12117 th.classList.add('sort-'+shSortOrder);
12118 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12119 shPage=1;renderShPage();
12120 }});
12121 }});
12122 var table=document.getElementById('scan-history-table');
12123 if(!table)return;
12124 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12125 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12126 allThs.forEach(function(th,i){{
12127 var handle=th.querySelector('.col-resize-handle');
12128 if(!handle||!cols[i])return;
12129 var startX,startW;
12130 handle.addEventListener('mousedown',function(e){{
12131 e.stopPropagation();e.preventDefault();
12132 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12133 handle.classList.add('dragging');
12134 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12135 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12136 document.addEventListener('mousemove',onMove);
12137 document.addEventListener('mouseup',onUp);
12138 }});
12139 }});
12140 }}
12141
12142 function renderTable(pts, yKey){{
12143 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12144 var wrap=document.getElementById('data-table-wrap');
12145 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12146 var yLabel=Y_LABELS[yKey]||yKey||'';
12147 shData=pts.slice().reverse();
12148 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12149 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12150 var branches={{}};
12151 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12152 var branchOpts='<option value="">All branches</option>';
12153 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12154 wrap.innerHTML=
12155 '<div class="chart-section-header">SCAN HISTORY</div>'+
12156 '<div class="filter-row">'+
12157 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12158 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12159 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12160 '</div>'+
12161 '<div class="table-wrap">'+
12162 '<table id="scan-history-table" class="data-table">'+
12163 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12164 '<thead><tr id="sh-thead">'+
12165 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12166 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12167 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12168 '<th>Commit<div class="col-resize-handle"></div></th>'+
12169 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12170 '<th>Tags<div class="col-resize-handle"></div></th>'+
12171 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12172 '<th>Report<div class="col-resize-handle"></div></th>'+
12173 '</tr></thead>'+
12174 '<tbody id="sh-tbody"></tbody>'+
12175 '</table>'+
12176 '</div>'+
12177 '<div class="pagination">'+
12178 '<span class="pagination-info" id="sh-pg-info"></span>'+
12179 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12180 '<div style="display:flex;align-items:center;gap:8px;">'+
12181 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12182 '<select class="filter-select" id="sh-per-page">'+
12183 '<option value="10">10 per page</option>'+
12184 '<option value="25" selected>25 per page</option>'+
12185 '<option value="50">50 per page</option>'+
12186 '<option value="100">100 per page</option>'+
12187 '</select>'+
12188 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12189 '</div>'+
12190 '</div>';
12191 wireTableBehavior();
12192 renderShPage();
12193 }}
12194
12195 function exportXLSX(){{
12196 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12197 var xbtn=document.getElementById('export-xlsx-btn');
12198 var xorig=xbtn?xbtn.innerHTML:'';
12199 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12200 var root=rootSel.value;
12201 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12202 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12203 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12204 buildAndDownloadXLSX(cm);
12205 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12206 }}
12207
12208 function buildAndDownloadXLSX(churnMap){{
12209 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12210 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12211 // (sorted is newest-first), so a given project/commit appears at most once.
12212 var seenPC={{}},dedup=[];
12213 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12214 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12215 var s1R=dedup.map(function(d){{
12216 var c=churnMap[d.run_id]||{{}};
12217 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];
12218 }});
12219 var pm={{}};
12220 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12221 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'];
12222 var s2R=Object.keys(pm).map(function(p){{
12223 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12224 var lat=sc[sc.length-1],fst=sc[0];
12225 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12226 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);
12227 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];
12228 }});
12229 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12230 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12231 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12232 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12233 }}
12234
12235 function buildXLSX(sheets,chartRows,chartRows2){{
12236 function s2b(s){{return new TextEncoder().encode(s);}}
12237 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12238 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;}}
12239 function crc32(d){{
12240 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;}}}}
12241 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12242 }}
12243 function buildSheet(hdr,rows,drawRid,withCtrl){{
12244 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12245 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12246 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12247 x+='<row r="1">';
12248 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12249 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12250 x+='</row>';
12251 rows.forEach(function(row,ri){{
12252 var rn=ri+2;
12253 x+='<row r="'+rn+'">';
12254 row.forEach(function(cell,ci){{
12255 var addr=col2l(ci+1)+rn;
12256 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12257 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12258 }});
12259 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>";}}
12260 x+='</row>';
12261 }});
12262 x+='</sheetData>';
12263 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12264 return x+'</worksheet>';
12265 }}
12266 function buildChartXML(rows){{
12267 var sn="'Scan History'";
12268 var nr=rows.length,er=nr+1;
12269 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'}}];
12270 var catCol='C',catIdx=2;
12271 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12272 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">';
12273 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12274 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>';
12275 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12276 sd.forEach(function(s,i){{
12277 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12278 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>';
12279 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12280 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>';
12281 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12282 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12283 x+='</c:strCache></c:strRef></c:cat>';
12284 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+'"/>';
12285 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12286 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12287 }});
12288 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12289 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>';
12290 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>';
12291 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12292 return x;
12293 }}
12294 function buildChartXML2(rows){{
12295 var sn="'By Project'";
12296 var nr=rows.length,er=nr+1;
12297 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'}}];
12298 var catCol='A',catIdx=0;
12299 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12300 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">';
12301 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12302 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>';
12303 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12304 sd.forEach(function(s,i){{
12305 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12306 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>';
12307 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12308 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>';
12309 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12310 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12311 x+='</c:strCache></c:strRef></c:cat>';
12312 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+'"/>';
12313 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12314 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12315 }});
12316 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12317 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>';
12318 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>';
12319 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12320 return x;
12321 }}
12322 function buildChartXML3(rows){{
12323 var sn="'Scan History'";
12324 var nr=rows.length,er=nr+1;
12325 var catCol='C',catIdx=2;
12326 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12327 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">';
12328 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12329 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12330 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12331 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>";
12332 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12333 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>';
12334 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>';
12335 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12336 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12337 x+='</c:strCache></c:strRef></c:cat>';
12338 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+'"/>';
12339 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12340 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12341 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12342 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>';
12343 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>';
12344 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>';
12345 return x;
12346 }}
12347 function buildFocusSheet(drawRid){{
12348 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12349 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12350 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12351 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12352 x+='<sheetData><row r="1">';
12353 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12354 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12355 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12356 x+='</row></sheetData>';
12357 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>';
12358 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12359 return x+'</worksheet>';
12360 }}
12361 var hasChart=!!(chartRows&&chartRows.length);
12362 var nr=hasChart?chartRows.length:0;
12363 var hasChart2=!!(chartRows2&&chartRows2.length);
12364 var nr2=hasChart2?chartRows2.length:0;
12365 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>';
12366 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"/>';
12367 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12368 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"/>';}}
12369 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"/>';}}
12370 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12371 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>';
12372 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12373 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"/>';}});
12374 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12375 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>';
12376 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12377 wbx+='</sheets></workbook>';
12378 var files=[
12379 {{name:'[Content_Types].xml',data:s2b(ct)}},
12380 {{name:'_rels/.rels',data:s2b(dotrels)}},
12381 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12382 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12383 {{name:'xl/styles.xml',data:s2b(styl)}}
12384 ];
12385 // Chart embedded directly in Scan History (sheet1); By Project is plain
12386 sheets.forEach(function(s,i){{
12387 var sx;
12388 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12389 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12390 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12391 }});
12392 if(hasChart){{
12393 var fromRow=nr+4,toRow=nr+34;
12394 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>')}});
12395 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12396 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">';
12397 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12398 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>';
12399 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>';
12400 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12401 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12402 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12403 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12404 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12405 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12406 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>')}});
12407 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12408 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>')}});
12409 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12410 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">';
12411 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12412 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>';
12413 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>';
12414 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12415 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12416 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12417 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12418 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12419 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12420 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>')}});
12421 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12422 }}
12423 if(hasChart2){{
12424 var fromRow2=nr2+4,toRow2=nr2+36;
12425 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>')}});
12426 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12427 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">';
12428 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12429 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>';
12430 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>';
12431 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12432 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12433 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12434 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12435 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12436 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12437 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>')}});
12438 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12439 }}
12440 var parts=[],offsets=[],total=0;
12441 files.forEach(function(f){{
12442 offsets.push(total);
12443 var nb=s2b(f.name),crc=crc32(f.data);
12444 var h=new DataView(new ArrayBuffer(30+nb.length));
12445 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12446 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12447 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12448 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12449 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12450 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12451 total+=30+nb.length+f.data.length;
12452 }});
12453 var cdStart=total;
12454 files.forEach(function(f,fi){{
12455 var nb=s2b(f.name),crc=crc32(f.data);
12456 var cd=new DataView(new ArrayBuffer(46+nb.length));
12457 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12458 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12459 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12460 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12461 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12462 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12463 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12464 }});
12465 var cdSz=total-cdStart;
12466 var eocd=new DataView(new ArrayBuffer(22));
12467 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12468 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12469 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12470 parts.push(new Uint8Array(eocd.buffer));
12471 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12472 var out=new Uint8Array(sz);var off=0;
12473 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12474 return out.buffer;
12475 }}
12476
12477 function trendTitleParts(){{
12478 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12479 var subSelEl=document.getElementById('sub-sel');
12480 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12481 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12482 var proj=(document.getElementById('root-sel').value)||'All projects';
12483 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12484 var cnt=(allData&&allData.length)||0;
12485 var now=new Date();
12486 function p2(n){{return(n<10?'0':'')+n;}}
12487 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12488 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12489 }}
12490
12491 function exportPNG(){{
12492 var svgEl=document.querySelector('#chart-wrap svg');
12493 if(!svgEl){{alert('No chart to export yet.');return;}}
12494 var svgStr=new XMLSerializer().serializeToString(svgEl);
12495 var vb=svgEl.viewBox.baseVal,scale=2;
12496 var headerH=84,footerH=36;
12497 var lw=(vb.width||900),lh=(vb.height||380);
12498 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12499 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12500 var url=URL.createObjectURL(blob);
12501 var img=new Image();
12502 var tp=trendTitleParts();
12503 img.onload=function(){{
12504 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12505 var ctx=canvas.getContext('2d');
12506 var cs=getComputedStyle(document.body);
12507 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12508 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12509 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12510 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12511 ctx.scale(scale,scale);
12512 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12513 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12514 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12515 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12516 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;
12517 ctx.drawImage(img,0,headerH);
12518 var fy=headerH+lh;
12519 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;
12520 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12521 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);
12522 ctx.textAlign='left';
12523 URL.revokeObjectURL(url);
12524 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12525 }};
12526 img.src=url;
12527 }}
12528
12529 function exportPDF(){{
12530 var svgEl=document.querySelector('#chart-wrap svg');
12531 if(!svgEl){{alert('No chart to export yet.');return;}}
12532 var tp=trendTitleParts();
12533 var svgStr=new XMLSerializer().serializeToString(svgEl);
12534 var statsEl=document.getElementById('trend-stats');
12535 var statsHtml=statsEl?statsEl.innerHTML:'';
12536 var yK=document.getElementById('y-sel').value;
12537 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12538 var yL=yLabels[yK]||yK;
12539 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12540 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>';
12541 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>';}});
12542 tableHtml+='</tbody></table>';
12543 var css='<style>'
12544 +'*{{box-sizing:border-box;}}'
12545 +'html,body{{margin:0;padding:0;}}'
12546 // Masthead/footer flow in document order — a position:fixed header repeats
12547 // on every printed page in Chromium and hides the rows beneath it on pages
12548 // 2+. The trend table's <thead> repeats per page natively instead.
12549 +'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;}}'
12550 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12551 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12552 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12553 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12554 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12555 +'.rep-body{{padding:22px 34px 0;}}'
12556 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12557 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12558 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12559 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12560 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12561 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12562 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12563 +'.stat-chip-tip{{display:none!important;}}'
12564 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12565 +'.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;}}'
12566 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12567 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12568 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12569 +'.rep-chart svg{{max-width:100%;height:auto;}}'
12570 +'.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;}}'
12571 +'.filter-row{{display:none!important;}}'
12572 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12573 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12574 +'th{{background:#f0e9e0;font-weight:800;}}'
12575 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12576 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12577 +'.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;}}'
12578 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12579 +'</style>';
12580 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12581 +'<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>'
12582 +'<div class="rep-body">'
12583 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12584 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12585 +'<div class="summary-strip">'+statsHtml+'</div>'
12586 +'<div class="rep-chart">'+svgStr+'</div>'
12587 +tableHtml
12588 +'</div>'
12589 +'<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>'
12590 +'</body></html>';
12591 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12592 }}
12593
12594 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12595 var el=document.getElementById(id);
12596 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12597 }});
12598 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12599 // tracks the container like the responsive Chart.js charts do.
12600 var _rsT=null;
12601 window.addEventListener('resize',function(){{
12602 if(_rsT)clearTimeout(_rsT);
12603 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12604 }});
12605 rootSel.addEventListener('change',function(){{
12606 populateSubmodules(rootSel.value);
12607 loadAndRender();
12608 }});
12609 if(subSel)subSel.addEventListener('change',loadAndRender);
12610
12611 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12612 (function(){{
12613 var fvBtn=document.getElementById('tr-chart-fv-btn');
12614 if(!fvBtn)return;
12615 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12616 fvBtn.addEventListener('click',function(){{
12617 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12618 var yKey=document.getElementById('y-sel').value;
12619 var xMode=document.getElementById('x-sel').value;
12620 var pts=allData;
12621 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12622 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12623 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12624 var tp=trendTitleParts();
12625 var ov=document.createElement('div');
12626 ov.className='tr-chart-full-modal';
12627 ov.innerHTML='<div class="tr-chart-full-inner">'
12628 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12629 +'<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>'
12630 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12631 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12632 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12633 document.body.appendChild(ov);
12634 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12635 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12636 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12637 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12638 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12639 }});
12640 }})();
12641
12642 var xlsxBtn=document.getElementById('export-xlsx-btn');
12643 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12644 var pngBtn=document.getElementById('export-png-btn');
12645 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12646 var pdfBtn=document.getElementById('export-pdf-btn');
12647 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12648
12649 // ── Clean-up modal ───────────────────────────────────────────────────────
12650 (function(){{
12651 var triggerBtn=document.getElementById('cleanup-runs-btn');
12652 if(!triggerBtn)return;
12653 var modal=document.createElement('div');
12654 modal.className='tr-modal-backdrop';
12655 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12656 +'<div class="tr-modal-head">'
12657 +'<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>'
12658 +'<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>'
12659 +'</div>'
12660 +'<div class="tr-modal-body">'
12661 +'<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>'
12662 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12663 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12664 +'<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;">'
12665 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12666 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12667 +'</div>'
12668 +'<div class="tr-modal-foot">'
12669 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12670 +'<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>'
12671 +'</div></div>';
12672 document.body.appendChild(modal);
12673 triggerBtn.addEventListener('click',function(){{
12674 document.getElementById('cleanup-status').style.display='none';
12675 modal.style.display='flex';
12676 }});
12677 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12678 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12679 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12680 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12681 var confirmBtn=this;
12682 confirmBtn.disabled=true;
12683 var status=document.getElementById('cleanup-status');
12684 status.style.display='block';
12685 status.style.background='#dbeafe';status.style.color='#1e40af';
12686 status.textContent='Deleting\u2026';
12687 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12688 .then(function(resp){{
12689 return resp.json().then(function(d){{
12690 if(resp.ok){{
12691 status.style.background='#dcfce7';status.style.color='#166534';
12692 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12693 setTimeout(function(){{window.location.reload();}},1500);
12694 }}else{{
12695 status.style.background='#fee2e2';status.style.color='#991b1b';
12696 status.textContent='Error: '+(d.error||'Unexpected error');
12697 confirmBtn.disabled=false;
12698 }}
12699 }});
12700 }})
12701 .catch(function(e){{
12702 status.style.background='#fee2e2';status.style.color='#991b1b';
12703 status.textContent='Network error: '+String(e);
12704 confirmBtn.disabled=false;
12705 }});
12706 }});
12707 }})();
12708
12709 // ── Retention policy panel ────────────────────────────────────────────────
12710 (function(){{
12711 var triggerBtn=document.getElementById('retention-policy-btn');
12712 if(!triggerBtn)return;
12713 var modal=document.createElement('div');
12714 modal.className='tr-modal-backdrop';
12715 modal.style.zIndex='9001';
12716 modal.innerHTML=''
12717 +'<div class="tr-modal" style="max-width:640px;">'
12718 +'<div class="tr-modal-head">'
12719 +'<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>'
12720 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12721 +'</div>'
12722 +'<div class="tr-modal-body">'
12723 +'<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>'
12724 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12725 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12726 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12727 +'</div>'
12728 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12729 +'<div>'
12730 +'<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>'
12731 +'<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;">'
12732 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12733 +'</div>'
12734 +'<div>'
12735 +'<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>'
12736 +'<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;">'
12737 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12738 +'</div>'
12739 +'</div>'
12740 +'<div style="margin-bottom:20px;">'
12741 +'<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>'
12742 +'<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;">'
12743 +'<option value="1">Every hour</option>'
12744 +'<option value="6">Every 6 hours</option>'
12745 +'<option value="12">Every 12 hours</option>'
12746 +'<option value="24" selected>Every 24 hours</option>'
12747 +'<option value="48">Every 2 days</option>'
12748 +'<option value="72">Every 3 days</option>'
12749 +'<option value="168">Every week</option>'
12750 +'</select>'
12751 +'</div>'
12752 +'<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>'
12753 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12754 +'</div>'
12755 +'<div class="tr-modal-foot">'
12756 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12757 +'<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>'
12758 +'<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>'
12759 +'</div>'
12760 +'</div>';
12761 document.body.appendChild(modal);
12762
12763 function rpShowStatus(msg,ok){{
12764 var s=document.getElementById('rp-status');
12765 s.style.display='block';
12766 s.style.background=ok?'#dcfce7':'#fee2e2';
12767 s.style.color=ok?'#166534':'#991b1b';
12768 s.textContent=msg;
12769 }}
12770 function fmtAgo(iso){{
12771 if(!iso)return'Never';
12772 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12773 if(diff<60)return diff+'s ago';
12774 if(diff<3600)return Math.floor(diff/60)+'m ago';
12775 if(diff<86400)return Math.floor(diff/3600)+'h ago';
12776 return Math.floor(diff/86400)+'d ago';
12777 }}
12778 function loadPolicy(){{
12779 fetch('/api/cleanup-policy')
12780 .then(function(r){{return r.json();}})
12781 .then(function(d){{
12782 var p=d.policy;
12783 document.getElementById('rp-enabled').checked=p?p.enabled:false;
12784 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12785 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12786 var sel=document.getElementById('rp-interval');
12787 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;}}}}}}
12788 var lr=document.getElementById('rp-last-run');
12789 if(d.last_run_at){{
12790 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'):'');
12791 }}else{{
12792 lr.textContent='Auto-cleanup has not run yet.';
12793 }}
12794 }})
12795 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12796 }}
12797
12798 triggerBtn.addEventListener('click',function(){{
12799 document.getElementById('rp-status').style.display='none';
12800 loadPolicy();
12801 modal.style.display='flex';
12802 }});
12803 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12804 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12805
12806 document.getElementById('rp-save-btn').addEventListener('click',function(){{
12807 var enabled=document.getElementById('rp-enabled').checked;
12808 var ageVal=document.getElementById('rp-max-age').value.trim();
12809 var countVal=document.getElementById('rp-max-count').value.trim();
12810 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12811 if(enabled&&!ageVal&&!countVal){{
12812 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12813 return;
12814 }}
12815 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12816 var saveBtn=document.getElementById('rp-save-btn');
12817 saveBtn.disabled=true;
12818 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12819 .then(function(r){{
12820 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12821 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12822 }})
12823 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12824 .finally(function(){{saveBtn.disabled=false;}});
12825 }});
12826
12827 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12828 var btn=this;
12829 var orig=btn.innerHTML;
12830 btn.disabled=true;
12831 btn.textContent='Running\u2026';
12832 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12833 .then(function(r){{return r.json();}})
12834 .then(function(d){{
12835 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12836 loadPolicy();
12837 }})
12838 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12839 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12840 }});
12841 }})();
12842
12843 populateSubmodules(rootSel.value);
12844 loadAndRender();
12845
12846 (function randomizeWatermarks() {{
12847 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12848 if (!wms.length) return;
12849 var placed = [];
12850 function tooClose(top, left) {{
12851 for (var i = 0; i < placed.length; i++) {{
12852 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12853 if (dt < 16 && dl < 12) return true;
12854 }}
12855 return false;
12856 }}
12857 function pick(leftBand) {{
12858 for (var attempt = 0; attempt < 50; attempt++) {{
12859 var top = Math.random() * 88 + 2;
12860 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12861 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
12862 }}
12863 var top = Math.random() * 88 + 2;
12864 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12865 placed.push([top, left]); return [top, left];
12866 }}
12867 var half = Math.floor(wms.length / 2);
12868 wms.forEach(function (img, i) {{
12869 var pos = pick(i < half);
12870 var size = Math.floor(Math.random() * 100 + 120);
12871 var rot = (Math.random() * 360).toFixed(1);
12872 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12873 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;
12874 }});
12875 }})();
12876 (function spawnCodeParticles() {{
12877 var container = document.getElementById('code-particles');
12878 if (!container) return;
12879 var snippets = [
12880 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12881 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12882 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12883 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12884 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
12885 ];
12886 var count = 38;
12887 for (var i = 0; i < count; i++) {{
12888 (function(idx) {{
12889 var el = document.createElement('span');
12890 el.className = 'code-particle';
12891 el.textContent = snippets[idx % snippets.length];
12892 var left = Math.random() * 94 + 2;
12893 var top = Math.random() * 88 + 6;
12894 var dur = (Math.random() * 10 + 9).toFixed(1);
12895 var delay = (Math.random() * 18).toFixed(1);
12896 var rot = (Math.random() * 26 - 13).toFixed(1);
12897 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12898 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
12899 container.appendChild(el);
12900 }})(i);
12901 }}
12902 }})();
12903 </script>
12904 <footer class="site-footer">
12905 local code analysis - metrics, history and reports
12906 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
12907 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12908 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12909 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12910 · <a href="/api-docs" rel="noopener">REST API</a>
12911 </footer>
12912 <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>
12913 {toast_assets}
12914</body>
12915</html>"##,
12916 );
12917
12918 Html(html).into_response()
12919}
12920
12921fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12922 use std::collections::HashMap;
12923 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
12924 return vec![];
12925 }
12926 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
12927 for rec in per_file_records {
12928 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
12929 let e = totals.entry(lang.display_name().to_string()).or_default();
12930 e.0 += u64::from(cov.lines_found);
12931 e.1 += u64::from(cov.lines_hit);
12932 }
12933 }
12934 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
12936 .into_iter()
12937 .filter(|(_, (found, _))| *found > 0)
12938 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
12939 .collect();
12940 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
12941 pairs
12942 .iter()
12943 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
12944 .collect()
12945}
12946
12947fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
12948 let mut high = 0u64;
12949 let mut mid = 0u64;
12950 let mut low = 0u64;
12951 for rec in per_file_records {
12952 if let Some(cov) = &rec.coverage {
12953 if cov.lines_found == 0 {
12954 continue;
12955 }
12956 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
12957 if pct >= 80.0 {
12958 high += 1;
12959 } else if pct >= 50.0 {
12960 mid += 1;
12961 } else {
12962 low += 1;
12963 }
12964 }
12965 }
12966 (high, mid, low)
12967}
12968
12969fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12970 let mut arr: Vec<serde_json::Value> = per_file_records
12971 .iter()
12972 .filter_map(|rec| {
12973 rec.coverage.as_ref().map(|cov| {
12974 let line_pct = if cov.lines_found > 0 {
12975 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
12976 / 10.0
12977 } else {
12978 0.0
12979 };
12980 let fn_pct = if cov.functions_found > 0 {
12981 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
12982 .round()
12983 / 10.0
12984 } else {
12985 -1.0
12986 };
12987 serde_json::json!({
12988 "rel": rec.relative_path,
12989 "lang": rec.language.map_or("?", |l| l.display_name()),
12990 "line_pct": line_pct,
12991 "fn_pct": fn_pct,
12992 "lhit": cov.lines_hit,
12993 "lfound": cov.lines_found,
12994 "fhit": cov.functions_hit,
12995 "ffound": cov.functions_found,
12996 })
12997 })
12998 })
12999 .collect();
13000 arr.sort_by(|a, b| {
13001 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13002 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13003 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13004 });
13005 arr
13006}
13007
13008#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13010 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13011 .totals_by_language
13012 .iter()
13013 .filter(|l| l.test_count > 0)
13014 .collect();
13015 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13016 let lang_tests: Vec<serde_json::Value> = langs
13017 .iter()
13018 .map(|l| {
13019 let d = if l.code_lines > 0 {
13020 l.test_count as f64 / l.code_lines as f64 * 1000.0
13021 } else {
13022 0.0
13023 };
13024 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13025 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13026 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13027 })
13028 .collect();
13029 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13030 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13031 let t = &run.summary_totals;
13032 let total_tests = t.test_count;
13033 let density = if t.code_lines > 0 {
13034 total_tests as f64 / t.code_lines as f64 * 1000.0
13035 } else {
13036 0.0
13037 };
13038 let most_tested = langs.first().map_or_else(
13039 || "\u{2014}".to_string(),
13040 |l| l.language.display_name().to_string(),
13041 );
13042 let test_files: u64 = run
13043 .per_file_records
13044 .iter()
13045 .filter(|f| f.raw_line_categories.test_count > 0)
13046 .count() as u64;
13047 let cov_line = if t.coverage_lines_found > 0 {
13048 format!(
13049 "{:.1}",
13050 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13051 )
13052 } else {
13053 "0".to_string()
13054 };
13055 let cov_fn = if t.coverage_functions_found > 0 {
13056 format!(
13057 "{:.1}",
13058 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13059 )
13060 } else {
13061 "0".to_string()
13062 };
13063 let cov_branch = if t.coverage_branches_found > 0 {
13064 format!(
13065 "{:.1}",
13066 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13067 )
13068 } else {
13069 "0".to_string()
13070 };
13071 let has_cov = !cov_arr.is_empty();
13072 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13073 serde_json::json!({
13074 "totals": {
13075 "test_count": total_tests,
13076 "assertions": t.test_assertion_count,
13077 "suites": t.test_suite_count,
13078 "test_files": test_files,
13079 "total_files": t.files_analyzed,
13080 "density_str": format!("{density:.1}"),
13081 "most_tested": most_tested,
13082 "langs_with_tests": langs.len(),
13083 "cov_line": cov_line,
13084 "cov_fn": cov_fn,
13085 "cov_branch": cov_branch,
13086 },
13087 "lang_tests": lang_tests,
13088 "cov": cov_arr,
13089 "cov_tiers": {"high": high, "mid": mid, "low": low},
13090 "file_cov": file_cov_arr,
13091 "has_coverage": has_cov,
13092 "submodules": {},
13093 })
13094}
13095
13096#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13098 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13099 .language_summaries
13100 .iter()
13101 .filter(|l| l.test_count > 0)
13102 .collect();
13103 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13104 let lang_tests: Vec<serde_json::Value> = langs
13105 .iter()
13106 .map(|l| {
13107 let d = if l.code_lines > 0 {
13108 l.test_count as f64 / l.code_lines as f64 * 1000.0
13109 } else {
13110 0.0
13111 };
13112 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13113 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13114 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13115 })
13116 .collect();
13117 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13118 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13119 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13120 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13121 let density = if sub.code_lines > 0 {
13122 total_tests as f64 / sub.code_lines as f64 * 1000.0
13123 } else {
13124 0.0
13125 };
13126 let most_tested = langs.first().map_or_else(
13127 || "\u{2014}".to_string(),
13128 |l| l.language.display_name().to_string(),
13129 );
13130 serde_json::json!({
13131 "totals": {
13132 "test_count": total_tests,
13133 "assertions": total_assertions,
13134 "suites": total_suites,
13135 "test_files": test_files_approx,
13136 "total_files": sub.files_analyzed,
13137 "density_str": format!("{density:.1}"),
13138 "most_tested": most_tested,
13139 "langs_with_tests": langs.len(),
13140 "cov_line": "0",
13141 "cov_fn": "0",
13142 "cov_branch": "0",
13143 },
13144 "lang_tests": lang_tests,
13145 "cov": [],
13146 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13147 "has_coverage": false,
13148 })
13149}
13150
13151fn compute_cov_json_str(run: &AnalysisRun) -> String {
13152 use std::collections::HashMap;
13153 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13154 for rec in &run.per_file_records {
13155 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13156 let e = totals.entry(lang.display_name().to_string()).or_default();
13157 e.0 += u64::from(cov.lines_found);
13158 e.1 += u64::from(cov.lines_hit);
13159 }
13160 }
13161 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13163 .into_iter()
13164 .filter(|(_, (found, _))| *found > 0)
13165 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13166 .collect();
13167 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13168 let parts: Vec<String> = pairs
13169 .iter()
13170 .map(|(lang, pct)| {
13171 let name = lang.replace('"', "\\\"");
13172 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13173 })
13174 .collect();
13175 format!("[{}]", parts.join(","))
13176}
13177
13178fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13179 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13180 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13181}
13182
13183fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13184 let mut entry = build_test_scope_entry(run);
13185 if !run.submodule_summaries.is_empty() {
13186 let subs: serde_json::Map<String, serde_json::Value> = run
13187 .submodule_summaries
13188 .iter()
13189 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13190 .collect();
13191 entry["submodules"] = serde_json::Value::Object(subs);
13192 }
13193 entry
13194}
13195
13196fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13197 let name = l.language.display_name().replace('"', "\\\"");
13198 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13200 l.test_count as f64 / l.code_lines as f64 * 1000.0
13201 } else {
13202 0.0
13203 };
13204 format!(
13205 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13206 name = name,
13207 t = l.test_count,
13208 a = l.test_assertion_count,
13209 s = l.test_suite_count,
13210 c = l.code_lines,
13211 d = density,
13212 f = l.files,
13213 )
13214}
13215
13216fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13217 let Some(r) = run else {
13218 return "[]".to_string();
13219 };
13220 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13221 .totals_by_language
13222 .iter()
13223 .filter(|l| l.test_count > 0)
13224 .collect();
13225 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13226 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13227 format!("[{}]", parts.join(","))
13228}
13229
13230async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13232 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13233 scope_map.insert(
13234 "__all__".to_string(),
13235 latest_run.map_or_else(
13236 || {
13237 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13238 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13239 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13240 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13241 "has_coverage":false,"submodules":{}})
13242 },
13243 build_test_scope_entry,
13244 ),
13245 );
13246 let all_roots: Vec<String> = {
13247 let reg = state.registry.lock().await;
13248 let mut seen = std::collections::BTreeSet::new();
13249 reg.entries
13250 .iter()
13251 .flat_map(|e| e.input_roots.iter().cloned())
13252 .filter(|r| seen.insert(r.clone()))
13253 .collect()
13254 };
13255 for root in &all_roots {
13256 let json_path = {
13257 let reg = state.registry.lock().await;
13258 reg.entries
13259 .iter()
13260 .find(|e| e.input_roots.iter().any(|r| r == root))
13261 .and_then(|e| e.json_path.clone())
13262 };
13263 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13264 let json_str = tokio::fs::read_to_string(&p).await.ok();
13265 json_str
13266 .as_deref()
13267 .and_then(|s| serde_json::from_str(s).ok())
13268 } else {
13269 None
13270 };
13271 if let Some(ref run) = run_for_root {
13272 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13273 }
13274 }
13275 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13276}
13277
13278#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13282 State(state): State<AppState>,
13283 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13284) -> Response {
13285 auto_scan_watched_dirs(&state).await;
13286 let watched_dirs_list: Vec<String> = {
13287 let wd = state.watched_dirs.lock().await;
13288 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13289 };
13290 let latest_run: Option<AnalysisRun> = {
13291 let json_path = {
13292 let reg = state.registry.lock().await;
13293 reg.entries.first().and_then(|e| e.json_path.clone())
13294 };
13295 if let Some(p) = json_path {
13296 let json_str = tokio::fs::read_to_string(&p).await.ok();
13297 json_str
13298 .as_deref()
13299 .and_then(|s| serde_json::from_str(s).ok())
13300 } else {
13301 None
13302 }
13303 };
13304
13305 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13307
13308 let cov_json: String = latest_run
13310 .as_ref()
13311 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13312 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13313
13314 let _cov_tier_json: String = latest_run
13316 .as_ref()
13317 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13318 .map_or_else(
13319 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13320 compute_cov_tier_json_str,
13321 );
13322
13323 let total_tests: u64 = latest_run
13324 .as_ref()
13325 .map_or(0, |r| r.summary_totals.test_count);
13326 let total_assertions: u64 = latest_run
13327 .as_ref()
13328 .map_or(0, |r| r.summary_totals.test_assertion_count);
13329 let total_suites: u64 = latest_run
13330 .as_ref()
13331 .map_or(0, |r| r.summary_totals.test_suite_count);
13332 let total_code: u64 = latest_run
13333 .as_ref()
13334 .map_or(0, |r| r.summary_totals.code_lines);
13335 let workspace_density: f64 = if total_code > 0 {
13336 total_tests as f64 / total_code as f64 * 1000.0
13337 } else {
13338 0.0
13339 };
13340 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13341 r.totals_by_language
13342 .iter()
13343 .filter(|l| l.test_count > 0)
13344 .count()
13345 });
13346 let most_tested: String = latest_run
13347 .as_ref()
13348 .and_then(|r| {
13349 r.totals_by_language
13350 .iter()
13351 .filter(|l| l.test_count > 0)
13352 .max_by_key(|l| l.test_count)
13353 })
13354 .map_or_else(
13355 || "\u{2014}".to_string(),
13356 |l| l.language.display_name().to_string(),
13357 );
13358 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13359 r.per_file_records
13360 .iter()
13361 .filter(|f| f.raw_line_categories.test_count > 0)
13362 .count() as u64
13363 });
13364 let total_files_analyzed: u64 = latest_run
13365 .as_ref()
13366 .map_or(0, |r| r.summary_totals.files_analyzed);
13367 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13368
13369 let cov_line_pct_str: String = latest_run
13371 .as_ref()
13372 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13373 .map_or_else(
13374 || "0".to_string(),
13375 |r| {
13376 format!(
13377 "{:.1}",
13378 r.summary_totals.coverage_lines_hit as f64
13379 / r.summary_totals.coverage_lines_found as f64
13380 * 100.0
13381 )
13382 },
13383 );
13384 let cov_fn_pct_str: String = latest_run
13385 .as_ref()
13386 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13387 .map_or_else(
13388 || "0".to_string(),
13389 |r| {
13390 format!(
13391 "{:.1}",
13392 r.summary_totals.coverage_functions_hit as f64
13393 / r.summary_totals.coverage_functions_found as f64
13394 * 100.0
13395 )
13396 },
13397 );
13398 let cov_branch_pct_str: String = latest_run
13399 .as_ref()
13400 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13401 .map_or_else(
13402 || "0".to_string(),
13403 |r| {
13404 format!(
13405 "{:.1}",
13406 r.summary_totals.coverage_branches_hit as f64
13407 / r.summary_totals.coverage_branches_found as f64
13408 * 100.0
13409 )
13410 },
13411 );
13412
13413 let cov_no_data_notice = if has_coverage {
13414 String::new()
13415 } else {
13416 String::from(
13417 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13418<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>
13419<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13420 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13421 <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>
13422 <span style="color:var(--muted);font-size:12px;">·</span>
13423 <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>
13424 <span style="color:var(--muted);font-size:12px;">·</span>
13425 <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>
13426 <span style="color:var(--muted);font-size:12px;">·</span>
13427 <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>
13428 <span style="color:var(--muted);font-size:12px;">·</span>
13429 <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>
13430</div>
13431<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13432</div>"#,
13433 )
13434 };
13435
13436 let workspace_density_str = format!("{workspace_density:.1}");
13437 let nonce = &csp_nonce;
13438 let toast_assets = sloc_toast_assets(nonce);
13439 let version = env!("CARGO_PKG_VERSION");
13440
13441 let watched_dirs_html: String = if state.server_mode {
13444 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()
13445 } else {
13446 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13447 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13448 .to_string()
13449 } else {
13450 watched_dirs_list
13451 .iter()
13452 .fold(String::new(), |mut s, d| {
13453 use std::fmt::Write as _;
13454 let escaped =
13455 d.replace('&', "&").replace('"', """).replace('<', "<");
13456 write!(
13457 s,
13458 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>"#
13459 ).expect("write to String is infallible");
13460 s
13461 })
13462 };
13463 format!(
13464 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>"#
13465 )
13466 };
13467
13468 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13470
13471 let html = format!(
13472 r#"<!doctype html>
13473<html lang="en">
13474<head>
13475 <meta charset="utf-8" />
13476 <meta name="viewport" content="width=device-width, initial-scale=1" />
13477 <title>OxideSLOC | Test Metrics</title>
13478 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13479 <style nonce="{nonce}">
13480 :root {{
13481 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13482 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13483 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13484 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13485 --info-bg:#eef3ff; --info-text:#4467d8;
13486 }}
13487 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13488 *{{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;}}
13489 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13490 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13491 .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;}}
13492 @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));}}}}
13493 .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);}}
13494 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13495 .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));}}
13496 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13497 .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;}}
13498 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13499 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13500 @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; }} }}
13501 .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;}}
13502 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13503 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13504 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13505 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13506 .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;}}
13507 .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;}}
13508 .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;}}
13509 .settings-modal{{position:fixed;z-index:9999;background:var(--surface);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;}}
13510 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13511 .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);}}
13512 .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;}}
13513 .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;}}
13514 .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;}}
13515 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13516 .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;}}
13517 .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);}}
13518 .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;}}
13519 .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;}}
13520 .tz-select:focus{{border-color:var(--oxide);}}
13521 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13522 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13523 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13524 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13525 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13526 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13527 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13528 .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);}}
13529 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13530 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13531 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13532 .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;}}
13533 .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;}}
13534 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13535 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13536 .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);}}
13537 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13538 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13539 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13540 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13541 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13542 .chart-canvas-wrap{{position:relative;height:280px;}}
13543 .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;}}
13544 .chart-no-data svg{{opacity:0.35;}}
13545 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13546 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13547 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13548 .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;}}
13549 .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;}}
13550 .data-table tr:last-child td{{border-bottom:none;}}
13551 .data-table tbody tr:hover td{{background:var(--surface-2);}}
13552 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13553 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13554 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13555 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13556 .cov-gauge-card{{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;}}
13557 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13558 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13559 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13560 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13561 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13562 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13563 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13564 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13565 .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;}}
13566 .chart-select:focus{{border-color:var(--accent);}}
13567 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13568 .trend-canvas-wrap{{position:relative;height:260px;}}
13569 .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;}}
13570 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13571 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13572 .site-footer a{{color:var(--muted);}}
13573 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13574 .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;}}
13575 .btn:hover{{background:var(--surface-2);}}
13576 .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;}}
13577 .export-btn:hover{{background:var(--line);}}
13578 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13579 .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;}}
13580 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13581 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13582 .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;}}
13583 .scope-sel:focus{{border-color:var(--accent);}}
13584 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13585 .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;}}
13586 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13587 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13588 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13589 .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;}}
13590 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13591 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13592 .watched-chip-rm:hover{{color:var(--oxide);}}
13593 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13594 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13595 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13596 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13597 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13598 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13599 .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;}}
13600 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13601 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13602 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13603 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13604 .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;}}
13605 .cov-file-search:focus{{border-color:var(--accent);}}
13606 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13607 .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;}}
13608 body.dark-theme .cov-file-search{{background:var(--surface);}}
13609 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13610 .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;}}
13611 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13612 .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;}}
13613 .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);}}
13614 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13615 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13616 .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;}}
13617 .chart-modal-close:hover{{opacity:.7;}}
13618 body.dark-theme .chart-modal{{background:var(--surface);}}
13619 </style>
13620</head>
13621<body>
13622 <div class="background-watermarks" aria-hidden="true">
13623 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13624 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13625 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13626 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13627 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13628 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13629 </div>
13630 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13631 <div class="top-nav">
13632 <div class="top-nav-inner">
13633 <a class="brand" href="/">
13634 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13635 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13636 </a>
13637 <div class="nav-right">
13638 <a class="nav-pill" href="/">Home</a>
13639 <div class="nav-dropdown">
13640 <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>
13641 <div class="nav-dropdown-menu">
13642 <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>
13643 </div>
13644 </div>
13645 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13646 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13647 <div class="nav-dropdown">
13648 <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>
13649 <div class="nav-dropdown-menu">
13650 <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>
13651 </div>
13652 </div>
13653 <div class="server-status-wrap" id="server-status-wrap">
13654 <div class="nav-pill server-online-pill" id="server-status-pill">
13655 <span class="status-dot" id="status-dot"></span>
13656 <span id="server-status-label">Server</span>
13657 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13658 </div>
13659 <div class="server-status-tip">
13660 OxideSLOC is running — accessible on your network.
13661 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13662 </div>
13663 </div>
13664 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13665 <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>
13666 </button>
13667 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13668 <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>
13669 <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>
13670 </button>
13671 </div>
13672 </div>
13673 </div>
13674
13675 <div class="page">
13676 {watched_dirs_html}
13677 <div class="scope-bar">
13678 <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>
13679 <span class="scope-label">Scope</span>
13680 <div class="scope-sel-wrap">
13681 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13682 <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);">
13683 <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>
13684 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13685 </div>
13686 </div>
13687 </div>
13688 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13689 <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>
13690 <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>
13691 <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>
13692 <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>
13693 </div>
13694 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13695 <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>
13696 <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>
13697 <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>
13698 <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>
13699 </div>
13700
13701 <div class="panel" id="viz-panel">
13702 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13703 <span>Visualizations</span>
13704 <div style="display:flex;gap:8px;flex-wrap:wrap;">
13705 <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>
13706 <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>
13707 <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>
13708 </div>
13709 </div>
13710
13711 <div class="chart-box" style="margin-bottom:18px;">
13712 <div class="chart-box-header">
13713 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13714 <div style="display:flex;gap:8px;align-items:center;">
13715 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
13716 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13717 </div>
13718 </div>
13719 <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>
13720 <div class="trend-controls-bar">
13721 <label>Y Metric:
13722 <select class="chart-select" id="tm-trend-y">
13723 <option value="test_count" selected>Test Definitions</option>
13724 <option value="code_lines">Code Lines</option>
13725 </select>
13726 </label>
13727 <label>X Axis:
13728 <select class="chart-select" id="tm-trend-x">
13729 <option value="commit" selected>By Commit</option>
13730 <option value="time">By Time</option>
13731 </select>
13732 </label>
13733 <label id="tm-sub-label" style="display:none;">Submodule:
13734 <select class="chart-select" id="tm-trend-sub">
13735 <option value="">All (project total)</option>
13736 </select>
13737 </label>
13738 <label>Chart Size:
13739 <select class="chart-select" id="tm-trend-size">
13740 <option value="200">Compact</option>
13741 <option value="260" selected>Normal</option>
13742 <option value="360">Large</option>
13743 </select>
13744 </label>
13745 </div>
13746 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13747 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13748 </div>
13749
13750 <div class="chart-row">
13751 <div class="chart-box">
13752 <div class="chart-box-header">
13753 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13754 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13755 </div>
13756 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13757 <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>
13758 </div>
13759 <div class="chart-box">
13760 <div class="chart-box-header">
13761 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13762 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13763 </div>
13764 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13765 <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>
13766 </div>
13767 </div>
13768
13769 <div class="chart-row">
13770 <div class="chart-box">
13771 <div class="chart-box-header">
13772 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13773 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13774 </div>
13775 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13776 <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>
13777 </div>
13778 <div class="chart-box" id="suites-chart-box">
13779 <div class="chart-box-header">
13780 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13781 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
13782 </div>
13783 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13784 <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>
13785 </div>
13786 </div>
13787
13788 <div class="chart-row">
13789 <div class="chart-box">
13790 <div class="chart-box-title">Test Files Breakdown</div>
13791 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13792 <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>
13793 </div>
13794 <div class="chart-box">
13795 <div class="chart-box-title">Test Composition</div>
13796 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13797 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13798 <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>
13799 </div>
13800 </div>
13801 </div>
13802
13803 <div class="panel">
13804 <h1>Test Metrics</h1>
13805 <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>
13806
13807 <div class="section-header">Language Breakdown</div>
13808 {cov_no_data_notice}
13809 <div style="overflow-x:auto;">
13810 <table class="data-table" id="lang-table">
13811 <thead><tr>
13812 <th>Language</th>
13813 <th class="num">Test Fns</th>
13814 <th class="num">Assertions</th>
13815 <th class="num">Suites</th>
13816 <th class="num">Code Lines</th>
13817 <th class="num">Files</th>
13818 <th class="num">Density / 1K</th>
13819 <th>Relative Density</th>
13820 </tr></thead>
13821 <tbody id="lang-tbody"></tbody>
13822 </table>
13823 </div>
13824 </div>
13825
13826 <div class="panel" id="cov-panel" style="display:none;">
13827 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
13828 <div class="cov-gauge-row" id="cov-gauges">
13829 <div class="cov-gauge-card">
13830 <div class="cov-gauge-label">Line Coverage</div>
13831 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
13832 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
13833 <div class="cov-gauge-sub">Lines hit / instrumented</div>
13834 </div>
13835 <div class="cov-gauge-card">
13836 <div class="cov-gauge-label">Function Coverage</div>
13837 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
13838 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
13839 <div class="cov-gauge-sub">Functions hit / found</div>
13840 </div>
13841 <div class="cov-gauge-card">
13842 <div class="cov-gauge-label">Branch Coverage</div>
13843 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
13844 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
13845 <div class="cov-gauge-sub">Branches hit / found</div>
13846 </div>
13847 </div>
13848 <div class="chart-row">
13849 <div class="chart-box">
13850 <div class="chart-box-title">Line Coverage % by Language</div>
13851 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
13852 </div>
13853 <div class="chart-box">
13854 <div class="chart-box-title">Coverage Tier Distribution</div>
13855 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
13856 </div>
13857 </div>
13858
13859 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
13860 <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>
13861 <div class="cov-file-toolbar">
13862 <div class="cov-filter-tabs" id="cov-filter-tabs">
13863 <button class="cov-tab active" data-tier="all">All</button>
13864 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
13865 <button class="cov-tab" data-tier="low">Low (<50%)</button>
13866 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
13867 <button class="cov-tab" data-tier="high">High (≥80%)</button>
13868 </div>
13869 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename\u2026">
13870 </div>
13871 <div style="overflow-x:auto;">
13872 <table class="data-table" id="cov-file-table">
13873 <thead><tr>
13874 <th>File</th>
13875 <th>Lang</th>
13876 <th class="num">Line %</th>
13877 <th class="num">Lines Hit / Found</th>
13878 <th class="num">Fn %</th>
13879 <th class="num">Fns Hit / Found</th>
13880 </tr></thead>
13881 <tbody id="cov-file-tbody"></tbody>
13882 </table>
13883 </div>
13884 <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>
13885 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
13886 </div>
13887
13888 </div>
13889
13890 <footer class="site-footer">
13891 local code analysis - metrics, history and reports
13892 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
13893 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13894 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13895 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13896 · <a href="/api-docs" rel="noopener">REST API</a>
13897 </footer>
13898
13899 <script nonce="{nonce}">
13900 (function() {{
13901 // Theme
13902 var b = document.body;
13903 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
13904 var tgl = document.getElementById('theme-toggle');
13905 if (tgl) tgl.addEventListener('click', function() {{
13906 var d = b.classList.toggle('dark-theme');
13907 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
13908 }});
13909
13910 // Watermarks
13911 (function() {{
13912 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13913 if (!wms.length) return;
13914 var placed = [];
13915 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;}}
13916 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];}}
13917 var half=Math.floor(wms.length/2);
13918 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;}});
13919 }})();
13920
13921 // Code particles
13922 (function() {{
13923 var container = document.getElementById('code-particles');
13924 if (!container) return;
13925 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
13926 for (var i = 0; i < 36; i++) {{
13927 (function(idx) {{
13928 var el = document.createElement('span');
13929 el.className = 'code-particle';
13930 el.textContent = snippets[idx % snippets.length];
13931 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
13932 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
13933 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
13934 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';
13935 container.appendChild(el);
13936 }})(i);
13937 }}
13938 }})();
13939
13940 // Settings modal
13941 (function() {{
13942 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'}}];
13943 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);}});}}
13944 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
13945 var btn=document.getElementById('settings-btn');if(!btn)return;
13946 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13947 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>';
13948 document.body.appendChild(m);
13949 var g=document.getElementById('scheme-grid');
13950 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);}});
13951 var cl=document.getElementById('settings-close');
13952 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');}});
13953 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13954 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13955 }})();
13956
13957 // Watched folder picker
13958 (function() {{
13959 var btn = document.getElementById('add-watched-btn');
13960 if (!btn) return;
13961 btn.addEventListener('click', function() {{
13962 fetch('/pick-directory?kind=reports')
13963 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
13964 .then(function(data) {{
13965 if (!data.cancelled && data.selected_path) {{
13966 var form = document.createElement('form');
13967 form.method = 'POST';
13968 form.action = '/watched-dirs/add';
13969 var ri = document.createElement('input');
13970 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
13971 var fi = document.createElement('input');
13972 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
13973 form.appendChild(ri); form.appendChild(fi);
13974 document.body.appendChild(form);
13975 form.submit();
13976 }}
13977 }})
13978 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
13979 }});
13980 }})();
13981 }})();
13982 </script>
13983
13984 <script src="/static/chart.js" nonce="{nonce}"></script>
13985 <script nonce="{nonce}">
13986 (function() {{
13987 var SCOPE_DATA = {scope_data_json};
13988 var currentRoot = '__all__';
13989 var currentSub = '';
13990 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
13991 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
13992 var ALL_CHARTS = [];
13993 var currentLangTests = [];
13994 var currentTrendPts = [];
13995
13996 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();}}
13997 function fmtFull(n){{return Number(n).toLocaleString();}}
13998 function isDark(){{return document.body.classList.contains('dark-theme');}}
13999 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14000 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14001 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14002
14003 function makeDlPlugin(fmtFn, anchor) {{
14004 return {{
14005 afterDatasetsDraw: function(chart) {{
14006 var ctx = chart.ctx;
14007 var tc = txtClr();
14008 chart.data.datasets.forEach(function(ds, di) {{
14009 var meta = chart.getDatasetMeta(di);
14010 meta.data.forEach(function(el, idx) {{
14011 var label = fmtFn(ds.data[idx], di, idx);
14012 if (label == null || label === '') return;
14013 ctx.save();
14014 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14015 ctx.fillStyle = tc;
14016 if (anchor === 'top') {{
14017 ctx.textAlign = 'center';
14018 ctx.textBaseline = 'bottom';
14019 ctx.fillText(String(label), el.x, el.y - 5);
14020 }} else {{
14021 ctx.textAlign = 'left';
14022 ctx.textBaseline = 'middle';
14023 ctx.fillText(String(label), el.x + 5, el.y);
14024 }}
14025 ctx.restore();
14026 }});
14027 }});
14028 }}
14029 }};
14030 }}
14031
14032 // Cursor: pointer over chart data, default over empty chart area.
14033 function chartCursor(e, els) {{
14034 var t = e.native && e.native.target;
14035 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14036 }}
14037 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14038
14039 // Plugin: draws % labels inside each doughnut slice.
14040 var donutPctPlugin = {{
14041 afterDatasetsDraw: function(chart) {{
14042 var ctx = chart.ctx;
14043 chart.data.datasets.forEach(function(ds, di) {{
14044 var meta = chart.getDatasetMeta(di);
14045 if (meta.hidden) return;
14046 var total = 0;
14047 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14048 if (!total) return;
14049 meta.data.forEach(function(arc, i) {{
14050 if (arc.hidden) return;
14051 var val = ds.data[i] || 0;
14052 var pct = val / total * 100;
14053 if (pct < 3) return;
14054 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14055 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14056 var tx = arc.x + midR * Math.cos(midAngle);
14057 var ty = arc.y + midR * Math.sin(midAngle);
14058 ctx.save();
14059 ctx.textAlign = 'center';
14060 ctx.textBaseline = 'middle';
14061 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14062 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14063 ctx.shadowBlur = 3;
14064 ctx.fillStyle = '#fff';
14065 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14066 ctx.restore();
14067 }});
14068 }});
14069 }}
14070 }};
14071
14072 function makeTmOverlay(title, subtitle, h) {{
14073 var overlay = document.createElement('div');
14074 overlay.className = 'chart-modal-overlay';
14075 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14076 var ch = Math.min(h || 560, maxH);
14077 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14078 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>';
14079 document.body.appendChild(overlay);
14080 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14081 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14082 return document.getElementById('tm-modal-canvas');
14083 }}
14084
14085 function getDataset() {{
14086 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14087 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14088 return r;
14089 }}
14090 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14091
14092 function showNoData(id, show) {{
14093 var el = document.getElementById(id);
14094 if (!el) return;
14095 var wrap = el.previousElementSibling;
14096 el.style.display = show ? '' : 'none';
14097 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14098 }}
14099
14100 function renderTestCharts(D) {{
14101 currentLangTests = D || [];
14102 testsChart = destroyChart(testsChart);
14103 densityChart = destroyChart(densityChart);
14104 if (!D || !D.length) {{
14105 showNoData('no-data-tests', true);
14106 showNoData('no-data-density', true);
14107 return;
14108 }}
14109 showNoData('no-data-tests', false);
14110 showNoData('no-data-density', false);
14111 var top15 = D.slice(0, 15);
14112 var canvas1 = document.getElementById('canvas-tests');
14113 if (canvas1) {{
14114 testsChart = new Chart(canvas1, {{
14115 type: 'bar',
14116 data: {{
14117 labels: top15.map(function(d){{ return d.lang; }}),
14118 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14119 }},
14120 options: {{
14121 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14122 layout: {{ padding: {{ right: 64 }} }},
14123 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14124 scales: {{
14125 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14126 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14127 }}
14128 }},
14129 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14130 }});
14131 ALL_CHARTS.push(testsChart);
14132 }}
14133 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14134 var canvas2 = document.getElementById('canvas-density');
14135 if (canvas2) {{
14136 densityChart = new Chart(canvas2, {{
14137 type: 'bar',
14138 data: {{
14139 labels: topD.map(function(d){{ return d.lang; }}),
14140 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 }}]
14141 }},
14142 options: {{
14143 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14144 layout: {{ padding: {{ right: 64 }} }},
14145 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14146 scales: {{
14147 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14148 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14149 }}
14150 }},
14151 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14152 }});
14153 ALL_CHARTS.push(densityChart);
14154 }}
14155 }}
14156
14157 function renderAssertionsChart(D) {{
14158 assertionsChart = destroyChart(assertionsChart);
14159 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14160 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14161 var canvas = document.getElementById('canvas-assertions');
14162 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14163 showNoData('no-data-assertions', false);
14164 assertionsChart = new Chart(canvas, {{
14165 type: 'bar',
14166 data: {{
14167 labels: top15.map(function(d){{ return d.lang; }}),
14168 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14169 }},
14170 options: {{
14171 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14172 layout: {{ padding: {{ right: 64 }} }},
14173 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14174 scales: {{
14175 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14176 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14177 }}
14178 }},
14179 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14180 }});
14181 ALL_CHARTS.push(assertionsChart);
14182 }}
14183
14184 function renderSuitesChart(D) {{
14185 suitesChart = destroyChart(suitesChart);
14186 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14187 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14188 var canvas = document.getElementById('canvas-suites');
14189 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14190 showNoData('no-data-suites', false);
14191 suitesChart = new Chart(canvas, {{
14192 type: 'bar',
14193 data: {{
14194 labels: top15.map(function(d){{ return d.lang; }}),
14195 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 }}]
14196 }},
14197 options: {{
14198 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14199 layout: {{ padding: {{ right: 64 }} }},
14200 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14201 scales: {{
14202 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14203 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14204 }}
14205 }},
14206 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14207 }});
14208 ALL_CHARTS.push(suitesChart);
14209 }}
14210
14211 function renderFilesChart(totals) {{
14212 filesChart = destroyChart(filesChart);
14213 var canvas = document.getElementById('canvas-files');
14214 if (!canvas) return;
14215 var testF = totals.test_files || 0;
14216 var totalF = totals.total_files || 0;
14217 var nonTest = Math.max(0, totalF - testF);
14218 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14219 showNoData('no-data-files', false);
14220 var dark = isDark();
14221 filesChart = new Chart(canvas, {{
14222 type: 'doughnut',
14223 data: {{
14224 labels: ['Test Files', 'Non-Test Files'],
14225 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14226 }},
14227 options: {{
14228 responsive: true, maintainAspectRatio: false, cutout: '62%',
14229 onHover: chartCursor,
14230 plugins: {{
14231 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14232 generateLabels: function(chart) {{
14233 var ds = chart.data.datasets[0];
14234 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14235 return chart.data.labels.map(function(lbl, i) {{
14236 var val = ds.data[i] || 0;
14237 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14238 return {{
14239 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14240 fillStyle: ds.backgroundColor[i],
14241 strokeStyle: ds.borderColor,
14242 lineWidth: ds.borderWidth,
14243 hidden: false,
14244 index: i,
14245 datasetIndex: 0
14246 }};
14247 }});
14248 }}
14249 }},
14250 onHover: function(e, item, leg) {{
14251 var ch = leg.chart;
14252 var t = e.native && e.native.target;
14253 if (t) t.style.cursor = 'pointer';
14254 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14255 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14256 ch.update();
14257 }},
14258 onLeave: function(e, item, leg) {{
14259 var ch = leg.chart;
14260 var t = e.native && e.native.target;
14261 if (t) t.style.cursor = 'default';
14262 ch.setActiveElements([]);
14263 ch.tooltip.setActiveElements([], {{}});
14264 ch.update('none');
14265 }}
14266 }},
14267 tooltip: {{ callbacks: {{ label: function(ctx) {{
14268 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14269 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14270 }} }} }}
14271 }}
14272 }},
14273 plugins: [donutPctPlugin]
14274 }});
14275 ALL_CHARTS.push(filesChart);
14276 }}
14277
14278 function renderCompositionChart(totals) {{
14279 compositionChart = destroyChart(compositionChart);
14280 var canvas = document.getElementById('canvas-composition');
14281 if (!canvas) return;
14282 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14283 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14284 showNoData('no-data-composition', false);
14285 compositionChart = new Chart(canvas, {{
14286 type: 'bar',
14287 data: {{
14288 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14289 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14290 }},
14291 options: {{
14292 responsive: true, maintainAspectRatio: false,
14293 layout: {{ padding: {{ top: 22 }} }},
14294 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14295 scales: {{
14296 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14297 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14298 }}
14299 }},
14300 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14301 }});
14302 ALL_CHARTS.push(compositionChart);
14303 }}
14304
14305 function renderCovCharts(covD, tiers) {{
14306 covChart = destroyChart(covChart);
14307 tierChart = destroyChart(tierChart);
14308 var covCanvas = document.getElementById('canvas-cov');
14309 if (covCanvas && covD && covD.length) {{
14310 covChart = new Chart(covCanvas, {{
14311 type: 'bar',
14312 data: {{
14313 labels: covD.map(function(d){{ return d.lang; }}),
14314 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 }}]
14315 }},
14316 options: {{
14317 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14318 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14319 scales: {{
14320 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14321 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14322 }}
14323 }}
14324 }});
14325 ALL_CHARTS.push(covChart);
14326 }}
14327 var tierCanvas = document.getElementById('canvas-cov-tiers');
14328 if (tierCanvas && tiers) {{
14329 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14330 tierChart = new Chart(tierCanvas, {{
14331 type: 'doughnut',
14332 data: {{
14333 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14334 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14335 }},
14336 options: {{
14337 responsive: true, maintainAspectRatio: false, cutout: '62%',
14338 onHover: chartCursor,
14339 plugins: {{
14340 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14341 onHover: function(e, item, leg) {{
14342 var ch = leg.chart;
14343 var t = e.native && e.native.target;
14344 if (t) t.style.cursor = 'pointer';
14345 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14346 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14347 ch.update();
14348 }},
14349 onLeave: function(e, item, leg) {{
14350 var ch = leg.chart;
14351 var t = e.native && e.native.target;
14352 if (t) t.style.cursor = 'default';
14353 ch.setActiveElements([]);
14354 ch.tooltip.setActiveElements([], {{}});
14355 ch.update('none');
14356 }}
14357 }},
14358 tooltip: {{ callbacks: {{ label: function(ctx) {{
14359 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14360 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14361 }} }} }}
14362 }}
14363 }},
14364 plugins: [donutPctPlugin]
14365 }});
14366 ALL_CHARTS.push(tierChart);
14367 }}
14368 }}
14369
14370 function buildLangTable(D) {{
14371 var tbody = document.getElementById('lang-tbody');
14372 if (!tbody) return;
14373 if (!D || !D.length) {{
14374 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>';
14375 return;
14376 }}
14377 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14378 tbody.innerHTML = D.map(function(d) {{
14379 var barW = Math.round(d.density / maxDensity * 120);
14380 return '<tr>' +
14381 '<td><strong>' + d.lang + '</strong></td>' +
14382 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14383 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14384 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14385 '<td class="num">' + fmtFull(d.code) + '</td>' +
14386 '<td class="num">' + fmtFull(d.files) + '</td>' +
14387 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14388 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14389 '</tr>';
14390 }}).join('');
14391 }}
14392
14393 var covFileData = [];
14394 var covFileTier = 'all';
14395 var covFileSearch = '';
14396
14397 function pctBadge(pct) {{
14398 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14399 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14400 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14401 }}
14402
14403 function buildCovFileTable() {{
14404 var tbody = document.getElementById('cov-file-tbody');
14405 var empty = document.getElementById('cov-file-empty');
14406 var count = document.getElementById('cov-file-count');
14407 if (!tbody) return;
14408 var srch = covFileSearch.toLowerCase();
14409 var filtered = covFileData.filter(function(f) {{
14410 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14411 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14412 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14413 if (covFileTier === 'high' && f.line_pct < 80) return false;
14414 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14415 return true;
14416 }});
14417 if (!filtered.length) {{
14418 tbody.innerHTML = '';
14419 if (empty) empty.style.display = '';
14420 if (count) count.textContent = '';
14421 return;
14422 }}
14423 if (empty) empty.style.display = 'none';
14424 var shown = Math.min(filtered.length, 500);
14425 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14426 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14427 var fnCol = f.fn_pct < 0
14428 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14429 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14430 return '<tr>' +
14431 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
14432 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14433 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14434 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14435 fnCol +
14436 '</tr>';
14437 }}).join('');
14438 }}
14439
14440 (function() {{
14441 var tabs = document.getElementById('cov-filter-tabs');
14442 if (tabs) {{
14443 tabs.addEventListener('click', function(e) {{
14444 var btn = e.target.closest('.cov-tab');
14445 if (!btn) return;
14446 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14447 btn.classList.add('active');
14448 covFileTier = btn.getAttribute('data-tier');
14449 buildCovFileTable();
14450 }});
14451 }}
14452 var srch = document.getElementById('cov-file-search');
14453 if (srch) {{
14454 srch.addEventListener('input', function() {{
14455 covFileSearch = this.value;
14456 buildCovFileTable();
14457 }});
14458 }}
14459 }})();
14460
14461 function updateCovGauges(t) {{
14462 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14463 var el;
14464 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14465 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14466 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14467 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14468 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14469 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14470 }}
14471
14472 function applyScope() {{
14473 var d = getDataset();
14474 var t = d.totals;
14475 var el;
14476 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14477 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14478 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14479 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14480 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14481 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14482 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14483 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14484 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14485 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14486 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14487 renderTestCharts(d.lang_tests);
14488 renderAssertionsChart(d.lang_tests);
14489 renderSuitesChart(d.lang_tests);
14490 renderFilesChart(t);
14491 renderCompositionChart(t);
14492 buildLangTable(d.lang_tests);
14493 var covPanel = document.getElementById('cov-panel');
14494 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14495 if (d.has_coverage) {{
14496 renderCovCharts(d.cov, d.cov_tiers);
14497 updateCovGauges(t);
14498 covFileData = d.file_cov || [];
14499 covFileTier = 'all';
14500 covFileSearch = '';
14501 var tabs = document.getElementById('cov-filter-tabs');
14502 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14503 var srch = document.getElementById('cov-file-search');
14504 if (srch) srch.value = '';
14505 buildCovFileTable();
14506 }}
14507 loadTrend();
14508 }}
14509
14510 // Populate scope-root-sel from SCOPE_DATA keys
14511 (function() {{
14512 var sel = document.getElementById('scope-root-sel');
14513 if (!sel) return;
14514 Object.keys(SCOPE_DATA).forEach(function(k) {{
14515 if (k === '__all__') return;
14516 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14517 }});
14518 }})();
14519
14520 document.getElementById('scope-root-sel').addEventListener('change', function() {{
14521 currentRoot = this.value;
14522 currentSub = '';
14523 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14524 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14525 var subWrap = document.getElementById('scope-sub-wrap');
14526 var subSel = document.getElementById('scope-sub-sel');
14527 subSel.innerHTML = '<option value="">Entire project</option>';
14528 if (subNames.length) {{
14529 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14530 subWrap.style.display = 'flex';
14531 }} else {{
14532 subWrap.style.display = 'none';
14533 }}
14534 applyScope();
14535 }});
14536
14537 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14538 currentSub = this.value;
14539 applyScope();
14540 }});
14541
14542 var allTrendData = [];
14543
14544 var TM_Y_META = {{
14545 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14546 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
14547 }};
14548
14549 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14550 function hexRgb(hex) {{
14551 var h = String(hex).replace('#', '');
14552 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14553 var n = parseInt(h, 16);
14554 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14555 }}
14556 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14557 // tint at the top to transparent at the bottom (no flat solid block).
14558 function tmTrendGradient(ctx2, chartArea, color) {{
14559 var rgb = hexRgb(color);
14560 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14561 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
14562 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14563 g.addColorStop(1, 'rgba(' + rgb + ',0)');
14564 return g;
14565 }}
14566
14567 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14568 // so linear interpolation between adjacent points matches the drawn line).
14569 function tmLineYAt(chart, px) {{
14570 var meta = chart.getDatasetMeta(0);
14571 if (!meta || !meta.data || !meta.data.length) return null;
14572 var d = meta.data;
14573 if (px <= d[0].x) return d[0].y;
14574 for (var i = 1; i < d.length; i++) {{
14575 if (px <= d[i].x) {{
14576 var span = d[i].x - d[i - 1].x;
14577 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14578 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14579 }}
14580 }}
14581 return d[d.length - 1].y;
14582 }}
14583
14584 // Plugin: only show the tooltip / finger cursor when the pointer is over the
14585 // gradient fill (inside the plot and at/below the line) — never in the empty
14586 // space above the line. Outside the fill we retype the event as 'mouseout' so
14587 // the core interaction dismisses any active tooltip on its own.
14588 var tmFillGuard = {{
14589 id: 'tmFillGuard',
14590 beforeEvent: function(chart, args) {{
14591 var e = args.event;
14592 if (!e || e.type !== 'mousemove') return;
14593 var ca = chart.chartArea;
14594 if (!ca) return;
14595 var inFill = false;
14596 if (e.x >= ca.left && e.x <= ca.right) {{
14597 var ly = tmLineYAt(chart, e.x);
14598 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14599 }}
14600 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14601 if (!inFill) {{ e.type = 'mouseout'; }}
14602 }}
14603 }};
14604
14605 // Single source of truth for the test-metrics trend chart config so the inline
14606 // chart and the Full View modal render identically (straight segments, gradient
14607 // fill, white-ringed points, gradient-only interactivity).
14608 function buildTmTrendConfig(pts, ctrl, meta) {{
14609 return {{
14610 type: 'line',
14611 data: {{
14612 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14613 datasets: [{{
14614 label: meta.label,
14615 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14616 borderColor: meta.color,
14617 borderWidth: 2.5,
14618 backgroundColor: function(context) {{
14619 var ca = context.chart.chartArea;
14620 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14621 return tmTrendGradient(context.chart.ctx, ca, meta.color);
14622 }},
14623 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14624 pointBorderColor: '#fff',
14625 pointBorderWidth: 2,
14626 pointRadius: 6,
14627 pointHoverRadius: 9,
14628 pointHoverBorderWidth: 2.5,
14629 fill: true, tension: 0
14630 }}]
14631 }},
14632 options: {{
14633 responsive: true, maintainAspectRatio: false,
14634 layout: {{ padding: {{ top: 22 }} }},
14635 interaction: {{ mode: 'index', intersect: false }},
14636 plugins: {{
14637 legend: {{ display: false }},
14638 tooltip: {{
14639 mode: 'index', intersect: false,
14640 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14641 }}
14642 }},
14643 scales: {{
14644 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14645 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14646 }}
14647 }},
14648 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14649 }};
14650 }}
14651
14652 function getTrendControls() {{
14653 var ySel = document.getElementById('tm-trend-y');
14654 var xSel = document.getElementById('tm-trend-x');
14655 var sizeSel = document.getElementById('tm-trend-size');
14656 var subSel = document.getElementById('tm-trend-sub');
14657 return {{
14658 yKey: ySel ? ySel.value : 'test_count',
14659 xMode: xSel ? xSel.value : 'commit',
14660 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
14661 submod: subSel ? subSel.value : ''
14662 }};
14663 }}
14664
14665 function makeTrendLabel(d, xMode) {{
14666 if (xMode === 'commit') {{
14667 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14668 }}
14669 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14670 }}
14671
14672 function buildTrend(data) {{
14673 allTrendData = data || [];
14674 renderTrend();
14675 }}
14676
14677 function renderTrend() {{
14678 var data = allTrendData;
14679 var ctrl = getTrendControls();
14680 var trendCanvas = document.getElementById('canvas-trend');
14681 var trendWrap = document.getElementById('trend-canvas-wrap');
14682 var trendEmpty = document.getElementById('trend-empty');
14683
14684 // Apply chart size
14685 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14686
14687 // Filter by submodule if selected (entries from project_label match)
14688 var pts = data.slice().reverse();
14689 if (ctrl.submod) {{
14690 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14691 }}
14692
14693 currentTrendPts = pts;
14694
14695 if (!pts.length) {{
14696 if (trendCanvas) trendCanvas.style.display = 'none';
14697 if (trendEmpty) trendEmpty.style.display = '';
14698 return;
14699 }}
14700 if (trendCanvas) trendCanvas.style.display = '';
14701 if (trendEmpty) trendEmpty.style.display = 'none';
14702
14703 trendChart = destroyChart(trendChart);
14704 if (!trendCanvas) return;
14705
14706 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14707
14708 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14709 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14710 ALL_CHARTS.push(trendChart);
14711
14712 // Populate submodule selector from unique project_labels
14713 var subSel = document.getElementById('tm-trend-sub');
14714 var subLabel = document.getElementById('tm-sub-label');
14715 if (subSel && data.length) {{
14716 var projects = [];
14717 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14718 if (projects.length > 1) {{
14719 var curVal = subSel.value;
14720 subSel.innerHTML = '<option value="">All (project total)</option>';
14721 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14722 if (subLabel) subLabel.style.display = '';
14723 }} else {{
14724 if (subLabel) subLabel.style.display = 'none';
14725 }}
14726 }}
14727 }}
14728
14729 // ── Full View expand buttons ──────────────────────────────────────────────
14730 (function() {{
14731 var btn = document.getElementById('tests-expand-btn');
14732 if (!btn) return;
14733 btn.addEventListener('click', function() {{
14734 var D = currentLangTests;
14735 if (!D || !D.length) return;
14736 var top15 = D.slice(0, 15);
14737 var h = Math.max(320, top15.length * 36 + 80);
14738 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14739 if (!canvas) return;
14740 new Chart(canvas, {{
14741 type: 'bar',
14742 data: {{
14743 labels: top15.map(function(d){{ return d.lang; }}),
14744 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14745 }},
14746 options: {{
14747 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14748 layout: {{ padding: {{ right: 72 }} }},
14749 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14750 scales: {{
14751 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14752 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14753 }}
14754 }},
14755 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14756 }});
14757 }});
14758 }})();
14759
14760 (function() {{
14761 var btn = document.getElementById('density-expand-btn');
14762 if (!btn) return;
14763 btn.addEventListener('click', function() {{
14764 var D = currentLangTests;
14765 if (!D || !D.length) return;
14766 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14767 var h = Math.max(320, topD.length * 36 + 80);
14768 var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14769 if (!canvas) return;
14770 new Chart(canvas, {{
14771 type: 'bar',
14772 data: {{
14773 labels: topD.map(function(d){{ return d.lang; }}),
14774 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 }}]
14775 }},
14776 options: {{
14777 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14778 layout: {{ padding: {{ right: 72 }} }},
14779 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14780 scales: {{
14781 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14782 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14783 }}
14784 }},
14785 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14786 }});
14787 }});
14788 }})();
14789
14790 (function() {{
14791 var btn = document.getElementById('trend-expand-btn');
14792 if (!btn) return;
14793 btn.addEventListener('click', function() {{
14794 var pts = currentTrendPts;
14795 if (!pts || !pts.length) return;
14796 var ctrl = getTrendControls();
14797 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14798 var title = meta.label + ' Trend \u2014 Full View';
14799 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14800 if (!canvas) return;
14801 // Reuse the exact inline-chart config so Full View matches the default view
14802 // (straight segments + gradient-only interactivity), just larger.
14803 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14804 }});
14805 }})();
14806
14807 (function() {{
14808 var btn = document.getElementById('assertions-expand-btn');
14809 if (!btn) return;
14810 btn.addEventListener('click', function() {{
14811 var D = currentLangTests;
14812 if (!D || !D.length) return;
14813 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14814 if (!top15.length) return;
14815 var h = Math.max(320, top15.length * 36 + 80);
14816 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14817 if (!canvas) return;
14818 new Chart(canvas, {{
14819 type: 'bar',
14820 data: {{
14821 labels: top15.map(function(d){{ return d.lang; }}),
14822 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14823 }},
14824 options: {{
14825 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14826 layout: {{ padding: {{ right: 72 }} }},
14827 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14828 scales: {{
14829 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14830 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14831 }}
14832 }},
14833 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14834 }});
14835 }});
14836 }})();
14837
14838 (function() {{
14839 var btn = document.getElementById('suites-expand-btn');
14840 if (!btn) return;
14841 btn.addEventListener('click', function() {{
14842 var D = currentLangTests;
14843 if (!D || !D.length) return;
14844 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14845 if (!top15.length) return;
14846 var h = Math.max(320, top15.length * 36 + 80);
14847 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
14848 if (!canvas) return;
14849 new Chart(canvas, {{
14850 type: 'bar',
14851 data: {{
14852 labels: top15.map(function(d){{ return d.lang; }}),
14853 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 }}]
14854 }},
14855 options: {{
14856 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14857 layout: {{ padding: {{ right: 72 }} }},
14858 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14859 scales: {{
14860 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14861 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14862 }}
14863 }},
14864 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14865 }});
14866 }});
14867 }})();
14868
14869 // Wire trend control selectors — re-render without re-fetching
14870 (function() {{
14871 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
14872 var el = document.getElementById(id);
14873 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
14874 }});
14875 }})();
14876
14877 function loadTrend() {{
14878 var url = '/api/metrics/history?limit=100';
14879 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
14880 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
14881 buildTrend(data);
14882 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
14883 var btn = document.getElementById('multi-compare-trend-btn');
14884 if (btn) {{
14885 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
14886 if (ids.length >= 2) {{
14887 btn.style.display = '';
14888 btn.onclick = function() {{
14889 // Reverse so oldest first (API returns newest first).
14890 var sorted = ids.slice().reverse();
14891 if (sorted.length === 2) {{
14892 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
14893 }} else {{
14894 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
14895 }}
14896 }};
14897 }} else {{
14898 btn.style.display = 'none';
14899 }}
14900 }}
14901 }}).catch(function(){{
14902 var trendEmpty = document.getElementById('trend-empty');
14903 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
14904 }});
14905 }}
14906
14907 // Re-render charts on theme toggle
14908 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
14909 setTimeout(function() {{
14910 ALL_CHARTS.forEach(function(c) {{
14911 if (c && c.options && c.options.scales) {{
14912 Object.values(c.options.scales).forEach(function(ax) {{
14913 if (ax.grid) ax.grid.color = clr();
14914 if (ax.ticks) ax.ticks.color = txtClr();
14915 }});
14916 c.update();
14917 }}
14918 }});
14919 }}, 80);
14920 }});
14921
14922 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
14923 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14924 function tmExportMeta() {{
14925 var sel = document.getElementById('scope-sel');
14926 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
14927 if (!proj || proj === '__all__') proj = 'All projects';
14928 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
14929 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
14930 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
14931 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
14932 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
14933 }}
14934
14935 function exportTmXLSX() {{
14936 var D = currentLangTests;
14937 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
14938 var t = tmExportMeta();
14939 function s2b(s) {{ return new TextEncoder().encode(s); }}
14940 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
14941 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; }}
14942 function crc32(d) {{
14943 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;}}}}
14944 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
14945 }}
14946 // Store all cells as strings so Excel left-aligns uniformly.
14947 function cs(addr, val, bold) {{
14948 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
14949 }}
14950 // Build an Excel Table XML definition for a given sheet range and columns.
14951 function makeTableXml(tblId, name, ref, cols) {{
14952 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
14953 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14954 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
14955 x+='<autoFilter ref="'+ref+'"/>';
14956 x+='<tableColumns count="'+cols.length+'">';
14957 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
14958 x+='</tableColumns>';
14959 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
14960 return x+'</table>';
14961 }}
14962 // Worksheet XML with optional Excel Table part reference.
14963 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
14964 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14965 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
14966 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
14967 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
14968 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
14969 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>';}});
14970 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>';}}
14971 x+='</sheetData>';
14972 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
14973 return x+'</worksheet>';
14974 }}
14975
14976 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
14977 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
14978 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
14979 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
14980 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
14981 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
14982
14983 // Sheet 1: Summary
14984 var sumHdr=['Metric','Value'];
14985 var sumRows=[
14986 ['Project / Scope', t.proj],
14987 ['Export Date', t.full],
14988 ['Test Functions', Number(totTests).toLocaleString()],
14989 ['Assertions', Number(totAssert).toLocaleString()],
14990 ['Test Suites', Number(totSuites).toLocaleString()],
14991 ['Languages with Tests', String(D.length)],
14992 ['Total Code Lines', Number(totCode).toLocaleString()],
14993 ['Average Density (per 1K)', String(avgDensity)],
14994 ];
14995 var sumRef='A1:B'+(sumRows.length+1);
14996 var sumTblXml=makeTableXml(1,'Summary',sumRef,sumHdr);
14997 var sumSheetXml=buildSheet(sumHdr,sumRows,null,[28,22],'rId1');
14998
14999 // Sheet 2: Language Breakdown
15000 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15001 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)];}});
15002 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15003 // Table covers header + data only (TOTAL row excluded from table, sits just below)
15004 var langDataRows=langRows.length;
15005 var langTblRef='A1:G'+(langDataRows+1);
15006 var langTblXml=makeTableXml(2,'LangBreakdown',langTblRef,langHdr);
15007 var langSheetXml=buildSheet(langHdr,langRows,totRow,[22,15,15,15,15,12,15],'rId1');
15008
15009 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>';
15010 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"/><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/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/tables/table2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15011 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>';
15012 var wbr='<?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/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
15013 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><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="Language Breakdown" sheetId="2" r:id="rId2"/></sheets></workbook>';
15014 var sh1rels='<?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/table1.xml"/></Relationships>';
15015 var sh2rels='<?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/table2.xml"/></Relationships>';
15016 var files=[
15017 {{name:'[Content_Types].xml',data:s2b(ct)}},
15018 {{name:'_rels/.rels',data:s2b(dotrels)}},
15019 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15020 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15021 {{name:'xl/styles.xml',data:s2b(styl)}},
15022 {{name:'xl/worksheets/sheet1.xml',data:s2b(sumSheetXml)}},
15023 {{name:'xl/worksheets/sheet2.xml',data:s2b(langSheetXml)}},
15024 {{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b(sh1rels)}},
15025 {{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b(sh2rels)}},
15026 {{name:'xl/tables/table1.xml',data:s2b(sumTblXml)}},
15027 {{name:'xl/tables/table2.xml',data:s2b(langTblXml)}},
15028 ];
15029 var parts=[],offsets=[],total=0;
15030 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;}});
15031 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;}});
15032 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));
15033 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;}});
15034 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15035 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15036 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15037 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15038 }}
15039
15040 function exportTmPNG() {{
15041 // Map canvas IDs to display titles
15042 var CHART_TITLES = {{
15043 'canvas-trend': 'TEST COUNT TREND',
15044 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15045 'canvas-density': 'TEST DENSITY (PER 1,000 CODE LINES)',
15046 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15047 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15048 'canvas-files': 'TEST FILES BREAKDOWN',
15049 'canvas-composition': 'TEST COMPOSITION'
15050 }};
15051 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15052 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15053 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15054 var t=tmExportMeta();
15055 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15056 var trendCanvas=document.getElementById('canvas-trend');
15057 var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15058 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15059 var TOTAL_W=COLW*2+GAP;
15060 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15061 TREND_H=Math.min(Math.max(200,TREND_H),340);
15062 // Per-row chart heights (2-col grid)
15063 var gridRows=Math.ceil(gridCanvases.length/2);
15064 var rowHeights=[];
15065 for(var ri=0;ri<gridRows;ri++){{
15066 var rh=240;
15067 for(var ci=0;ci<2;ci++){{
15068 var cv=gridCanvases[ri*2+ci];
15069 if(cv&&cv.width>0){{
15070 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15071 rh=Math.max(rh,Math.min(420,nat));
15072 }}
15073 }}
15074 rowHeights.push(rh);
15075 }}
15076 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15077 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15078 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15079 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15080 var ctx=out.getContext('2d');
15081 var cs2=getComputedStyle(document.body);
15082 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15083 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15084 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15085
15086 // Background
15087 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15088
15089 // Orange header block
15090 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15091 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15092 ctx.fillText('Test Metrics — '+t.proj,22,42);
15093 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15094 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15095 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15096
15097 // Helper: draw a section title label
15098 function drawTitle(label, x, y, w) {{
15099 ctx.save();
15100 ctx.fillStyle=oxide;
15101 ctx.font='700 11px '+TM_FONT;
15102 ctx.textBaseline='middle';
15103 ctx.textAlign='left';
15104 ctx.letterSpacing='0.07em';
15105 ctx.fillText(label, x+2, y+TITLE_H/2);
15106 // Underline
15107 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15108 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15109 ctx.globalAlpha=1;
15110 ctx.restore();
15111 }}
15112
15113 var yOff=HEADER_H;
15114
15115 // Trend chart (full width)
15116 if(hasTrend){{
15117 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15118 yOff+=TITLE_H;
15119 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15120 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15121 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15122 ctx.drawImage(surf,0,yOff);
15123 yOff+=TREND_H+ROW_PAD;
15124 }}
15125
15126 // Grid charts (2-col), each cell gets title + chart
15127 for(var gi=0;gi<gridRows;gi++){{
15128 var rh2=rowHeights[gi];
15129 // Draw row titles and charts
15130 for(var gci=0;gci<2;gci++){{
15131 var idx2=gi*2+gci;
15132 if(idx2>=gridCanvases.length)continue;
15133 var gcv=gridCanvases[idx2];
15134 var gx=gci*(COLW+GAP);
15135 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15136 }}
15137 yOff+=TITLE_H;
15138 for(var gci2=0;gci2<2;gci2++){{
15139 var idx3=gi*2+gci2;
15140 if(idx3>=gridCanvases.length)continue;
15141 var gcv2=gridCanvases[idx3];
15142 var gx2=gci2*(COLW+GAP);
15143 var natW=gcv2.width,natH=gcv2.height;
15144 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15145 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15146 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15147 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15148 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15149 ctx.drawImage(surf2,gx2,yOff);
15150 }}
15151 yOff+=rh2+ROW_PAD;
15152 }}
15153
15154 // Dark footer
15155 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15156 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15157 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15158
15159 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15160 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15161 }}
15162
15163 function exportTmPDF() {{
15164 var D=currentLangTests;
15165 var t=tmExportMeta();
15166 var strips=document.querySelectorAll('.summary-strip');
15167 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15168 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15169 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15170 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15171 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15172 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15173 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15174 var rows='';
15175 (D||[]).forEach(function(d){{
15176 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15177 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15178 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15179 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15180 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15181 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15182 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15183 }});
15184 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15185 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15186 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15187 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15188 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15189 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15190 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15191 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>';
15192 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15193 +'html,body{{height:100%;margin:0;}}'
15194 +'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;}}'
15195 +'.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;}}'
15196 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15197 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15198 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15199 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15200 +'.rep-body{{padding:20px 32px;flex:1;}}'
15201 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15202 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15203 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15204 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15205 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15206 +'.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;}}'
15207 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15208 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15209 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15210 +'.n{{text-align:right;}}'
15211 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15212 +'.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;}}'
15213 +'</style>';
15214 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15215 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15216 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15217 +'<div class="rep-body">'+statsHtml
15218 +'<div class="section-hdr">Language Breakdown</div>'
15219 +tableHtml+'</div>'
15220 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15221 +'</body></html>';
15222 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15223 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:document.getElementById('tm-export-pdf-btn')}});
15224 }}
15225
15226 (function() {{
15227 var xBtn=document.getElementById('tm-export-xlsx-btn');
15228 var pngBtn=document.getElementById('tm-export-png-btn');
15229 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15230 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15231 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15232 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15233 }})();
15234
15235 applyScope();
15236 }})();
15237 </script>
15238 <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>
15239 {toast_assets}
15240</body>
15241</html>"#,
15242 );
15243 (
15244 [(axum::http::header::CACHE_CONTROL, "no-store")],
15245 Html(html),
15246 )
15247 .into_response()
15248}
15249
15250#[derive(Deserialize)]
15257struct EmbedQuery {
15258 run_id: Option<String>,
15259 theme: Option<String>,
15260}
15261
15262async fn embed_handler(
15263 State(state): State<AppState>,
15264 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15265 Query(query): Query<EmbedQuery>,
15266) -> Response {
15267 let entry = {
15268 let reg = state.registry.lock().await;
15269 query.run_id.as_ref().map_or_else(
15270 || reg.entries.first().cloned(),
15271 |id| reg.find_by_run_id(id).cloned(),
15272 )
15273 };
15274
15275 let Some(entry) = entry else {
15276 return Html(
15277 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15278 .to_string(),
15279 )
15280 .into_response();
15281 };
15282
15283 let dark = query.theme.as_deref() == Some("dark");
15284 let languages: Vec<(String, u64, u64)> = entry
15285 .json_path
15286 .as_ref()
15287 .and_then(|p| read_json(p).ok())
15288 .map(|run| {
15289 run.totals_by_language
15290 .iter()
15291 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15292 .collect()
15293 })
15294 .unwrap_or_default();
15295
15296 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15297}
15298
15299fn render_embed_widget(
15300 entry: &RegistryEntry,
15301 languages: &[(String, u64, u64)],
15302 dark: bool,
15303 csp_nonce: &str,
15304) -> String {
15305 let s = &entry.summary;
15306 let total = s.code_lines + s.comment_lines + s.blank_lines;
15307 let code_pct = s
15308 .code_lines
15309 .checked_mul(100)
15310 .and_then(|n| n.checked_div(total))
15311 .unwrap_or(0);
15312
15313 let (bg, fg, surface, muted, border) = if dark {
15314 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15315 } else {
15316 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15317 };
15318
15319 let mut lang_rows = String::new();
15320 for (name, files, code) in languages {
15321 write!(
15322 lang_rows,
15323 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15324 escape_html(name),
15325 format_number(*files),
15326 format_number(*code),
15327 )
15328 .ok();
15329 }
15330
15331 let lang_table = if lang_rows.is_empty() {
15332 String::new()
15333 } else {
15334 format!(
15335 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15336 )
15337 };
15338
15339 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15340 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15341 let project_esc = escape_html(&entry.project_label);
15342 let code_lines = format_number(s.code_lines);
15343 let comment_lines = format_number(s.comment_lines);
15344 let files = format_number(s.files_analyzed);
15345 let code_raw = s.code_lines;
15346 let comment_raw = s.comment_lines;
15347 let blank_raw = s.blank_lines;
15348
15349 format!(
15350 r#"<!doctype html>
15351<html lang="en">
15352<head>
15353 <meta charset="utf-8">
15354 <meta name="viewport" content="width=device-width,initial-scale=1">
15355 <title>OxideSLOC — {project_esc}</title>
15356 <script src="/static/chart.js"></script>
15357 <style nonce="{csp_nonce}">
15358 *{{box-sizing:border-box;margin:0;padding:0}}
15359 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15360 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15361 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15362 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15363 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15364 .card .v{{font-size:18px;font-weight:700}}
15365 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15366 .row{{display:flex;gap:12px;align-items:flex-start}}
15367 .pie{{width:120px;height:120px;flex-shrink:0}}
15368 .lt{{border-collapse:collapse;width:100%;flex:1}}
15369 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15370 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15371 .n{{text-align:right}}
15372 .footer{{margin-top:10px;color:{muted};font-size:10px}}
15373 </style>
15374</head>
15375<body>
15376 <h2>{project_esc}</h2>
15377 <div class="sub">{timestamp} · run {run_short}</div>
15378 <div class="cards">
15379 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15380 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15381 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15382 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15383 </div>
15384 <div class="row">
15385 <canvas class="pie" id="c"></canvas>
15386 {lang_table}
15387 </div>
15388 <div class="footer">oxide-sloc</div>
15389 <script nonce="{csp_nonce}">
15390 new Chart(document.getElementById('c'),{{
15391 type:'doughnut',
15392 data:{{
15393 labels:['Code','Comments','Blank'],
15394 datasets:[{{
15395 data:[{code_raw},{comment_raw},{blank_raw}],
15396 backgroundColor:['#4a78ee','#b35428','#aaa'],
15397 borderWidth:0
15398 }}]
15399 }},
15400 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15401 }});
15402 </script>
15403</body>
15404</html>"#
15405 )
15406}
15407
15408fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15413 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15414 OnceLock::new();
15415 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15416 let mut guard = map
15417 .lock()
15418 .unwrap_or_else(std::sync::PoisonError::into_inner);
15419 guard
15420 .entry(dir.to_path_buf())
15421 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15422 .clone()
15423}
15424
15425#[allow(clippy::too_many_lines)]
15426fn persist_run_artifacts(
15427 run: &sloc_core::AnalysisRun,
15428 report_html: &str,
15429 run_dir: &Path,
15430 report_title: &str,
15431 file_stem: &str,
15432 result_context: RunResultContext,
15433) -> Result<(RunArtifacts, PendingPdf)> {
15434 let dir_lock = output_dir_lock(run_dir);
15437 let _dir_guard = dir_lock
15438 .lock()
15439 .unwrap_or_else(std::sync::PoisonError::into_inner);
15440
15441 let html_dir = run_dir.join("html");
15443 let pdf_dir = run_dir.join("pdf");
15444 let excel_dir = run_dir.join("excel");
15445 let json_dir = run_dir.join("json");
15446 let submodules_dir = run_dir.join("submodules");
15447 for dir in &[
15448 run_dir,
15449 &html_dir,
15450 &pdf_dir,
15451 &excel_dir,
15452 &json_dir,
15453 &submodules_dir,
15454 ] {
15455 fs::create_dir_all(dir)
15456 .with_context(|| format!("failed to create directory {}", dir.display()))?;
15457 }
15458
15459 let html_path = {
15461 let path = html_dir.join(format!("report_{file_stem}.html"));
15462 fs::write(&path, report_html)
15463 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15464 Some(path)
15465 };
15466
15467 let json_path = {
15469 let path = json_dir.join(format!("result_{file_stem}.json"));
15470 let json = serde_json::to_string_pretty(run)
15471 .context("failed to serialize analysis run to JSON")?;
15472 fs::write(&path, json)
15473 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15474 Some(path)
15475 };
15476
15477 let (pdf_path, pending_pdf) = {
15479 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15480 match write_pdf_from_run(run, &pdf_dest) {
15481 Ok(()) => {
15482 eprintln!(
15483 "[oxide-sloc][pdf] native PDF written to {}",
15484 pdf_dest.display()
15485 );
15486 (Some(pdf_dest), None)
15487 }
15488 Err(native_err) => {
15489 eprintln!(
15490 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15491 );
15492 let source_html_path = html_path
15493 .as_ref()
15494 .expect("html_path always Some here")
15495 .clone();
15496 let pending = Some((source_html_path, pdf_dest.clone(), false));
15497 (Some(pdf_dest), pending)
15498 }
15499 }
15500 };
15501
15502 let csv_path = {
15504 let path = excel_dir.join(format!("report_{file_stem}.csv"));
15505 if let Err(e) = sloc_report::write_csv(run, &path) {
15506 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15507 None
15508 } else {
15509 Some(path)
15510 }
15511 };
15512
15513 let xlsx_path = {
15514 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15515 if let Err(e) = sloc_report::write_xlsx(run, &path) {
15516 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15517 None
15518 } else {
15519 Some(path)
15520 }
15521 };
15522
15523 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15525
15526 if run.effective_configuration.discovery.submodule_breakdown {
15528 let run_id = &run.tool.run_id;
15529 for s in &run.submodule_summaries {
15530 build_submodule_row(s, run, run_id, run_dir);
15531 }
15532 }
15533
15534 generate_offline_index(
15536 run,
15537 run_dir,
15538 file_stem,
15539 html_path.as_deref(),
15540 pdf_path.as_deref(),
15541 json_path.as_deref(),
15542 scan_config_path.as_deref(),
15543 &result_context,
15544 );
15545
15546 Ok((
15547 RunArtifacts {
15548 output_dir: run_dir.to_path_buf(),
15549 html_path,
15550 pdf_path,
15551 json_path,
15552 csv_path,
15553 xlsx_path,
15554 scan_config_path,
15555 report_title: report_title.to_string(),
15556 result_context,
15557 },
15558 pending_pdf,
15559 ))
15560}
15561
15562#[allow(clippy::too_many_arguments)]
15565#[allow(clippy::too_many_lines)]
15566#[allow(clippy::similar_names)]
15567fn generate_offline_index(
15568 run: &sloc_core::AnalysisRun,
15569 run_dir: &Path,
15570 file_stem: &str,
15571 html_path: Option<&Path>,
15572 pdf_path: Option<&Path>,
15573 json_path: Option<&Path>,
15574 scan_config_path: Option<&Path>,
15575 result_context: &RunResultContext,
15576) {
15577 let prev_entry = &result_context.prev_entry;
15578 let prev_scan_count = result_context.prev_scan_count;
15579 let project_path = &result_context.project_path;
15580
15581 let scan_delta = prev_entry.as_ref().and_then(|prev| {
15582 prev.json_path
15583 .as_ref()
15584 .and_then(|p| read_json(p).ok())
15585 .map(|prev_run| compute_delta(&prev_run, run))
15586 });
15587
15588 let files_analyzed = run.per_file_records.len() as u64;
15589 let files_skipped = run.skipped_file_records.len() as u64;
15590 let totals = sum_lang_totals(run);
15591
15592 let DeltaFields {
15593 prev_fa_str,
15594 prev_fs_str,
15595 prev_pl_str,
15596 prev_cl_str,
15597 prev_cml_str,
15598 prev_bl_str,
15599 delta_fa_str,
15600 delta_fa_class,
15601 delta_fs_str,
15602 delta_fs_class,
15603 delta_pl_str,
15604 delta_pl_class,
15605 delta_cl_str,
15606 delta_cl_class,
15607 delta_cml_str,
15608 delta_cml_class,
15609 delta_bl_str,
15610 delta_bl_class,
15611 delta_lines_added,
15612 delta_lines_removed,
15613 delta_lines_net_str,
15614 delta_lines_net_class,
15615 } = compute_delta_fields(
15616 prev_entry.as_ref(),
15617 &totals,
15618 files_analyzed,
15619 files_skipped,
15620 scan_delta.as_ref(),
15621 );
15622
15623 let git_commit_url = git_commit_url_for(run);
15624 let git_branch_url = git_branch_url_for(run);
15625 let scan_performed_by = scan_performed_by(run);
15626
15627 let make_rel = |p: Option<&Path>| -> Option<String> {
15629 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15630 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15631 };
15632
15633 let run_id = &run.tool.run_id;
15634
15635 let submodule_rows: Vec<SubmoduleRow> = run
15637 .submodule_summaries
15638 .iter()
15639 .map(|s| {
15640 let safe = sanitize_project_label(&s.name);
15641 let key = format!("sub_{safe}");
15642 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15643 SubmoduleRow {
15644 name: s.name.clone(),
15645 relative_path: s.relative_path.clone(),
15646 files_analyzed: s.files_analyzed,
15647 code_lines: s.code_lines,
15648 comment_lines: s.comment_lines,
15649 blank_lines: s.blank_lines,
15650 total_physical_lines: s.total_physical_lines,
15651 html_url: if sub_path.exists() {
15652 Some(format!("submodules/{key}.html"))
15653 } else {
15654 None
15655 },
15656 }
15657 })
15658 .collect();
15659
15660 let lang_chart_json = build_lang_chart_json(run);
15661
15662 let scan_config_rel =
15663 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15664
15665 let template = ResultTemplate {
15666 version: env!("CARGO_PKG_VERSION"),
15667 report_title: run.effective_configuration.reporting.report_title.clone(),
15668 project_path: project_path.clone(),
15669 output_dir: display_path(run_dir),
15670 run_id: run_id.clone(),
15671 run_id_short: run_id
15672 .split('-')
15673 .next_back()
15674 .unwrap_or(run_id)
15675 .chars()
15676 .take(7)
15677 .collect(),
15678 files_analyzed,
15679 files_skipped,
15680 physical_lines: totals.physical_lines,
15681 code_lines: totals.code_lines,
15682 comment_lines: totals.comment_lines,
15683 blank_lines: totals.blank_lines,
15684 mixed_lines: totals.mixed_lines,
15685 functions: totals.functions,
15686 classes: totals.classes,
15687 variables: totals.variables,
15688 imports: totals.imports,
15689 html_url: make_rel(html_path),
15690 pdf_url: make_rel(pdf_path),
15691 json_url: make_rel(json_path),
15692 html_download_url: make_rel(html_path),
15693 pdf_download_url: make_rel(pdf_path),
15694 json_download_url: make_rel(json_path),
15695 html_path: html_path.map(display_path),
15696 json_path: json_path.map(display_path),
15697 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15698 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15699 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15700 prev_fa_str,
15701 prev_fs_str,
15702 prev_pl_str,
15703 prev_cl_str,
15704 prev_cml_str,
15705 prev_bl_str,
15706 delta_fa_str,
15707 delta_fa_class,
15708 delta_fs_str,
15709 delta_fs_class,
15710 delta_pl_str,
15711 delta_pl_class,
15712 delta_cl_str,
15713 delta_cl_class,
15714 delta_cml_str,
15715 delta_cml_class,
15716 delta_bl_str,
15717 delta_bl_class,
15718 delta_lines_added,
15719 delta_lines_removed,
15720 delta_lines_net_str,
15721 delta_lines_net_class,
15722 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15723 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15724 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15725 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15726 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15727 git_branch: run.git_branch.clone(),
15728 git_branch_url,
15729 git_commit: run.git_commit_short.clone(),
15730 git_commit_long: run.git_commit_long.clone(),
15731 git_author: run.git_commit_author.clone(),
15732 git_commit_url,
15733 scan_performed_by,
15734 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
15735 os_display: format!(
15736 "{} / {}",
15737 run.environment.operating_system, run.environment.architecture
15738 ),
15739 test_count: run.summary_totals.test_count,
15740 test_assertion_count: run.summary_totals.test_assertion_count,
15741 current_scan_number: prev_scan_count + 1,
15742 prev_scan_count,
15743 submodule_rows,
15744 pdf_generating: false,
15745 scan_config_url: scan_config_rel,
15746 lang_chart_json,
15747 scatter_chart_json: build_scatter_chart_json(run),
15748 semantic_chart_json: build_semantic_chart_json(run),
15749 submodule_chart_json: build_submodule_chart_json(run),
15750 has_submodule_data: !run.submodule_summaries.is_empty(),
15751 has_semantic_data: run
15752 .totals_by_language
15753 .iter()
15754 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
15755 csp_nonce: String::new(),
15756 confluence_configured: false,
15757 server_mode: false,
15758 report_header_footer: run
15759 .effective_configuration
15760 .reporting
15761 .report_header_footer
15762 .clone(),
15763 is_offline: true,
15764 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
15765 lsloc: run.summary_totals.lsloc,
15766 uloc: run.uloc,
15767 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
15768 duplicate_group_count: run.duplicate_groups.len(),
15769 has_cocomo: run.cocomo.is_some(),
15770 cocomo_effort_str: run
15771 .cocomo
15772 .as_ref()
15773 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
15774 cocomo_duration_str: run
15775 .cocomo
15776 .as_ref()
15777 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
15778 cocomo_staff_str: run
15779 .cocomo
15780 .as_ref()
15781 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
15782 cocomo_ksloc_str: run
15783 .cocomo
15784 .as_ref()
15785 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
15786 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
15787 || "Organic".to_string(),
15788 |c| cocomo_mode_label(c.mode).to_string(),
15789 ),
15790 cocomo_mode_tooltip: run
15791 .cocomo
15792 .as_ref()
15793 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
15794 complexity_alert: 0,
15795 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
15796 cov_line_pct: cov_pct_str(
15797 run.summary_totals.coverage_lines_hit,
15798 run.summary_totals.coverage_lines_found,
15799 ),
15800 cov_fn_pct: cov_pct_str(
15801 run.summary_totals.coverage_functions_hit,
15802 run.summary_totals.coverage_functions_found,
15803 ),
15804 cov_branch_pct: cov_pct_str(
15805 run.summary_totals.coverage_branches_hit,
15806 run.summary_totals.coverage_branches_found,
15807 ),
15808 cov_lines_summary: cov_lines_summary_str(
15809 run.summary_totals.coverage_lines_hit,
15810 run.summary_totals.coverage_lines_found,
15811 ),
15812 };
15813
15814 if let Ok(html) = template.render() {
15815 let html = inline_offline_logos(&html);
15819 let index_path = run_dir.join("index.html");
15820 if let Err(e) = fs::write(&index_path, html) {
15821 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
15822 }
15823 }
15824}
15825
15826fn inline_offline_logos(html: &str) -> String {
15830 use base64::Engine;
15831 let text_uri = format!(
15832 "data:image/png;base64,{}",
15833 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
15834 );
15835 let small_uri = format!(
15836 "data:image/png;base64,{}",
15837 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
15838 );
15839 html.replace("/images/logo/logo-text.png", &text_uri)
15840 .replace("/images/logo/small-logo.png", &small_uri)
15841}
15842
15843fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
15846 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
15848 return Some(found);
15849 }
15850 find_scan_config_in_dir_flat(dir)
15852}
15853
15854fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
15855 let exact = dir.join("scan-config.json");
15856 if exact.exists() {
15857 return Some(exact);
15858 }
15859 fs::read_dir(dir).ok().and_then(|entries| {
15860 entries
15861 .filter_map(std::result::Result::ok)
15862 .find(|e| {
15863 let name = e.file_name();
15864 let name = name.to_string_lossy();
15865 name.starts_with("scan-config") && name.ends_with(".json")
15866 })
15867 .map(|e| e.path())
15868 })
15869}
15870
15871#[derive(Deserialize)]
15876struct ExportPdfRequest {
15877 html: String,
15878 #[serde(default)]
15879 filename: Option<String>,
15880}
15881
15882async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
15883 let html_content = body.html;
15884 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
15885 if html_content.is_empty() {
15886 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
15887 }
15888 let tmp_dir = std::env::temp_dir();
15890 let html_path = tmp_dir.join(format!(
15891 "sloc-export-{}.html",
15892 uuid::Uuid::new_v4().simple()
15893 ));
15894 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
15895 if let Err(e) = std::fs::write(&html_path, &html_content) {
15896 return (
15897 StatusCode::INTERNAL_SERVER_ERROR,
15898 format!("Failed to write temp HTML: {e}"),
15899 )
15900 .into_response();
15901 }
15902 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
15903 let _ = std::fs::remove_file(&html_path);
15904 if let Err(e) = pdf_result {
15905 let _ = std::fs::remove_file(&pdf_path);
15906 return (
15907 StatusCode::INTERNAL_SERVER_ERROR,
15908 format!("PDF generation failed: {e}"),
15909 )
15910 .into_response();
15911 }
15912 let pdf_bytes = match std::fs::read(&pdf_path) {
15913 Ok(b) => b,
15914 Err(e) => {
15915 let _ = std::fs::remove_file(&pdf_path);
15916 return (
15917 StatusCode::INTERNAL_SERVER_ERROR,
15918 format!("Failed to read PDF: {e}"),
15919 )
15920 .into_response();
15921 }
15922 };
15923 let _ = std::fs::remove_file(&pdf_path);
15924 let safe_name: String = filename
15925 .chars()
15926 .map(|c| {
15927 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
15928 c
15929 } else {
15930 '_'
15931 }
15932 })
15933 .collect();
15934 let disposition = format!("attachment; filename=\"{safe_name}\"");
15935 (
15936 [
15937 (header::CONTENT_TYPE, "application/pdf".to_string()),
15938 (header::CONTENT_DISPOSITION, disposition),
15939 ],
15940 pdf_bytes,
15941 )
15942 .into_response()
15943}
15944
15945async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
15946 let toml_str = match toml::to_string_pretty(&state.base_config) {
15947 Ok(s) => s,
15948 Err(e) => {
15949 return (
15950 StatusCode::INTERNAL_SERVER_ERROR,
15951 format!("serialization error: {e}"),
15952 )
15953 .into_response();
15954 }
15955 };
15956 (
15957 [
15958 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
15959 (
15960 header::CONTENT_DISPOSITION,
15961 "attachment; filename=\".oxide-sloc.toml\"",
15962 ),
15963 ],
15964 toml_str,
15965 )
15966 .into_response()
15967}
15968
15969#[derive(Serialize)]
15970struct OkResponse {
15971 ok: bool,
15972}
15973
15974#[derive(Serialize)]
15975struct SaveProfileResponse {
15976 ok: bool,
15977 id: String,
15978}
15979
15980#[derive(Serialize)]
15981struct ProfileListResponse {
15982 profiles: Vec<ScanProfile>,
15983}
15984
15985#[derive(Serialize)]
15986struct ImportConfigResponse {
15987 ok: bool,
15988 config: sloc_config::AppConfig,
15989}
15990
15991#[derive(Deserialize)]
15992struct ImportConfigBody {
15993 toml: String,
15994}
15995
15996async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
15997 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
15998 Ok(config) => {
15999 if let Err(e) = config.validate() {
16000 return error::unprocessable_entity(&e.to_string());
16001 }
16002 Json(ImportConfigResponse { ok: true, config }).into_response()
16003 }
16004 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16005 }
16006}
16007
16008async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16011 let store = state.scan_profiles.lock().await;
16012 Json(ProfileListResponse {
16013 profiles: store.profiles.clone(),
16014 })
16015}
16016
16017#[derive(Deserialize)]
16018struct SaveScanProfileBody {
16019 name: String,
16020 params: serde_json::Value,
16021}
16022
16023async fn api_save_scan_profile(
16024 State(state): State<AppState>,
16025 Json(body): Json<SaveScanProfileBody>,
16026) -> impl IntoResponse {
16027 if body.name.trim().is_empty() {
16028 return error::bad_request("name must not be empty");
16029 }
16030
16031 let id = uuid::Uuid::new_v4().to_string();
16032 let profile = ScanProfile {
16033 id: id.clone(),
16034 name: body.name.trim().to_string(),
16035 created_at: chrono::Utc::now().to_rfc3339(),
16036 params: body.params,
16037 };
16038
16039 let mut store = state.scan_profiles.lock().await;
16040 store.profiles.push(profile);
16041 if let Err(e) = store.save(&state.scan_profiles_path) {
16042 tracing::warn!("failed to persist scan profiles: {e}");
16043 }
16044 drop(store);
16045
16046 (
16047 StatusCode::CREATED,
16048 Json(SaveProfileResponse { ok: true, id }),
16049 )
16050 .into_response()
16051}
16052
16053async fn api_delete_scan_profile(
16054 State(state): State<AppState>,
16055 AxumPath(id): AxumPath<String>,
16056) -> impl IntoResponse {
16057 let mut store = state.scan_profiles.lock().await;
16058 let before = store.profiles.len();
16059 store.profiles.retain(|p| p.id != id);
16060 if store.profiles.len() == before {
16061 drop(store);
16062 return error::not_found("profile not found");
16063 }
16064 if let Err(e) = store.save(&state.scan_profiles_path) {
16065 tracing::warn!("failed to persist scan profiles: {e}");
16066 }
16067 drop(store);
16068 Json(OkResponse { ok: true }).into_response()
16069}
16070
16071fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16072 let value = raw.unwrap_or("out/web").trim();
16073 let path = if value.is_empty() {
16074 PathBuf::from("out/web")
16075 } else {
16076 PathBuf::from(value)
16077 };
16078
16079 if path.is_absolute() {
16080 path
16081 } else {
16082 workspace_root().join(path)
16083 }
16084}
16085
16086fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16088 std::env::var("SLOC_GIT_CLONES_DIR")
16089 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16090}
16091
16092pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16095 let safe: String = repo_url
16096 .chars()
16097 .map(|c| {
16098 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16099 c
16100 } else {
16101 '_'
16102 }
16103 })
16104 .take(80)
16105 .collect();
16106 clones_dir.join(safe)
16107}
16108
16109pub(crate) fn scan_path_to_artifacts(
16112 scan_path: &Path,
16113 base_config: &AppConfig,
16114 label: &str,
16115) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16116 let mut config = base_config.clone();
16117 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16118 label.clone_into(&mut config.reporting.report_title);
16119 let run = analyze(&config, "git", None, None)?;
16120 let html = render_html(&run)?;
16121 let run_id = run.tool.run_id.clone();
16122 let project_label = sanitize_project_label(label);
16123 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16124 let file_stem = {
16125 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16126 if commit.is_empty() {
16127 project_label
16128 } else {
16129 format!("{project_label}_{commit}")
16130 }
16131 };
16132 let (artifacts, _pending_pdf) = persist_run_artifacts(
16133 &run,
16134 &html,
16135 &output_dir,
16136 label,
16137 &file_stem,
16138 RunResultContext::default(),
16139 )?;
16140 Ok((run_id, artifacts, run))
16141}
16142
16143async fn restart_poll_schedules(state: &AppState) {
16145 let store = state.schedules.lock().await;
16146 let poll_schedules: Vec<_> = store
16147 .schedules
16148 .iter()
16149 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16150 .cloned()
16151 .collect();
16152 drop(store);
16153 for schedule in poll_schedules {
16154 let interval = schedule.interval_secs.unwrap_or(300);
16155 let st = state.clone();
16156 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16157 }
16158}
16159
16160async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16166 if state.tls_enabled {
16167 return;
16168 }
16169 let store = state.schedules.lock().await;
16170 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16171 s.kind == sloc_git::ScanScheduleKind::Webhook
16172 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16173 });
16174 drop(store);
16175 if has_gitlab_webhook {
16176 tracing::warn!(
16177 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16178 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16179 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16180 proxy so the token is not exposed in cleartext."
16181 );
16182 }
16183}
16184
16185fn split_patterns(raw: Option<&str>) -> Vec<String> {
16186 raw.unwrap_or("")
16187 .lines()
16188 .flat_map(|line| line.split(','))
16189 .map(str::trim)
16190 .filter(|part| !part.is_empty())
16191 .map(ToOwned::to_owned)
16192 .collect()
16193}
16194
16195#[must_use]
16196pub fn build_sub_run(
16197 parent: &AnalysisRun,
16198 sub: &sloc_core::SubmoduleSummary,
16199 parent_path: &str,
16200) -> AnalysisRun {
16201 let sub_files: Vec<_> = parent
16202 .per_file_records
16203 .iter()
16204 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16205 .cloned()
16206 .collect();
16207 let mut config = parent.effective_configuration.clone();
16208 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16209
16210 let mut functions = 0u64;
16212 let mut classes = 0u64;
16213 let mut variables = 0u64;
16214 let mut imports = 0u64;
16215 let mut test_count = 0u64;
16216 let mut test_assertion_count = 0u64;
16217 let mut test_suite_count = 0u64;
16218 let mut mixed_lines_separate = 0u64;
16219 let mut coverage_lines_found = 0u64;
16220 let mut coverage_lines_hit = 0u64;
16221 let mut coverage_functions_found = 0u64;
16222 let mut coverage_functions_hit = 0u64;
16223 let mut coverage_branches_found = 0u64;
16224 let mut coverage_branches_hit = 0u64;
16225 for r in &sub_files {
16226 functions += r.raw_line_categories.functions;
16227 classes += r.raw_line_categories.classes;
16228 variables += r.raw_line_categories.variables;
16229 imports += r.raw_line_categories.imports;
16230 test_count += r.raw_line_categories.test_count;
16231 test_assertion_count += r.raw_line_categories.test_assertion_count;
16232 test_suite_count += r.raw_line_categories.test_suite_count;
16233 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16234 if let Some(cov) = &r.coverage {
16235 coverage_lines_found += u64::from(cov.lines_found);
16236 coverage_lines_hit += u64::from(cov.lines_hit);
16237 coverage_functions_found += u64::from(cov.functions_found);
16238 coverage_functions_hit += u64::from(cov.functions_hit);
16239 coverage_branches_found += u64::from(cov.branches_found);
16240 coverage_branches_hit += u64::from(cov.branches_hit);
16241 }
16242 }
16243
16244 AnalysisRun {
16245 tool: parent.tool.clone(),
16246 environment: parent.environment.clone(),
16247 effective_configuration: config,
16248 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16249 summary_totals: SummaryTotals {
16250 files_considered: sub.files_analyzed,
16251 files_analyzed: sub.files_analyzed,
16252 files_skipped: 0,
16253 total_physical_lines: sub.total_physical_lines,
16254 code_lines: sub.code_lines,
16255 comment_lines: sub.comment_lines,
16256 blank_lines: sub.blank_lines,
16257 mixed_lines_separate,
16258 functions,
16259 classes,
16260 variables,
16261 imports,
16262 test_count,
16263 test_assertion_count,
16264 test_suite_count,
16265 coverage_lines_found,
16266 coverage_lines_hit,
16267 coverage_functions_found,
16268 coverage_functions_hit,
16269 coverage_branches_found,
16270 coverage_branches_hit,
16271 cyclomatic_complexity: 0,
16272 lsloc: None,
16273 },
16274 totals_by_language: sub.language_summaries.clone(),
16275 per_file_records: sub_files,
16276 skipped_file_records: vec![],
16277 warnings: vec![],
16278 submodule_summaries: vec![],
16279 git_commit_short: sub.git_commit_short.clone(),
16280 git_commit_long: sub.git_commit_long.clone(),
16281 git_branch: sub.git_branch.clone(),
16282 git_commit_author: sub.git_commit_author.clone(),
16283 git_commit_date: sub.git_commit_date.clone(),
16284 git_tags: None,
16285 git_nearest_tag: None,
16286 git_remote_url: sub.git_remote_url.clone(),
16287 style_summary: None,
16288 cocomo: None,
16289 uloc: 0,
16290 dryness_pct: None,
16291 duplicate_groups: vec![],
16292 duplicates_excluded: 0,
16293 }
16294}
16295
16296#[must_use]
16297pub fn sanitize_project_label(raw: &str) -> String {
16298 let candidate = raw
16301 .split(['/', '\\'])
16302 .rfind(|s| !s.is_empty())
16303 .unwrap_or("project");
16304
16305 let mut value = String::with_capacity(candidate.len());
16306 for ch in candidate.chars() {
16307 if ch.is_ascii_alphanumeric() {
16308 value.push(ch.to_ascii_lowercase());
16309 } else {
16310 value.push('-');
16311 }
16312 }
16313
16314 let compact = value.trim_matches('-').to_string();
16315 if compact.is_empty() {
16316 "project".to_string()
16317 } else {
16318 compact
16319 }
16320}
16321
16322fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16325 let s = path.to_string_lossy();
16326 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16327 return PathBuf::from(format!(r"\\{rest}"));
16328 }
16329 if let Some(rest) = s.strip_prefix(r"\\?\") {
16330 return PathBuf::from(rest);
16331 }
16332 path
16333}
16334
16335fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16338 let base = if let Some(rest) = remote.strip_prefix("git@") {
16339 let (host, path) = rest.split_once(':')?;
16340 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16341 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16342 remote
16343 .trim_end_matches('/')
16344 .trim_end_matches(".git")
16345 .to_owned()
16346 } else {
16347 return None;
16348 };
16349 let base = base.trim_end_matches('/');
16350 if base.contains("gitlab.com") || base.contains("gitlab.") {
16352 Some(format!("{base}/-/commit/{sha}"))
16353 } else if base.contains("bitbucket.org") {
16354 Some(format!("{base}/commits/{sha}"))
16355 } else {
16356 Some(format!("{base}/commit/{sha}"))
16357 }
16358}
16359
16360fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16363 let base = if let Some(rest) = remote.strip_prefix("git@") {
16364 let (host, path) = rest.split_once(':')?;
16365 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16366 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16367 remote
16368 .trim_end_matches('/')
16369 .trim_end_matches(".git")
16370 .to_owned()
16371 } else {
16372 return None;
16373 };
16374 let base = base.trim_end_matches('/');
16375 if base.contains("gitlab.com") || base.contains("gitlab.") {
16376 Some(format!("{base}/-/tree/{branch}"))
16377 } else {
16378 Some(format!("{base}/tree/{branch}"))
16379 }
16380}
16381
16382fn display_path(path: &Path) -> String {
16383 let s = path.to_string_lossy();
16384 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16389 return format!(r"\\{rest}");
16390 }
16391 if let Some(rest) = s.strip_prefix(r"\\?\") {
16392 return rest.to_owned();
16393 }
16394 s.into_owned()
16395}
16396
16397fn sanitize_path_str(s: &str) -> String {
16398 if let Some(rest) = s.strip_prefix("//?/UNC/") {
16402 return format!("//{rest}");
16403 }
16404 if let Some(rest) = s.strip_prefix("//?/") {
16405 return rest.to_owned();
16406 }
16407 display_path(Path::new(s))
16408}
16409
16410fn workspace_root() -> PathBuf {
16411 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16413 let p = PathBuf::from(root);
16414 if p.is_dir() {
16415 return p;
16416 }
16417 }
16418
16419 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16422}
16423
16424fn make_git_label(repo: &str, ref_name: &str) -> String {
16426 if repo.is_empty() || ref_name.is_empty() {
16427 return String::new();
16428 }
16429 let base = repo
16430 .trim_end_matches('/')
16431 .trim_end_matches(".git")
16432 .rsplit('/')
16433 .next()
16434 .unwrap_or("repo");
16435 let ref_safe: String = ref_name
16436 .chars()
16437 .map(|c| {
16438 if c.is_alphanumeric() || c == '-' || c == '.' {
16439 c
16440 } else {
16441 '_'
16442 }
16443 })
16444 .collect();
16445 format!("{base}_at_{ref_safe}_sloc")
16446}
16447
16448fn desktop_dir() -> PathBuf {
16450 if let Ok(profile) = std::env::var("USERPROFILE") {
16451 let p = PathBuf::from(profile).join("Desktop");
16452 if p.exists() {
16453 return p;
16454 }
16455 }
16456 if let Ok(home) = std::env::var("HOME") {
16457 let p = PathBuf::from(home).join("Desktop");
16458 if p.exists() {
16459 return p;
16460 }
16461 }
16462 workspace_root().join("out").join("web")
16463}
16464
16465fn resolve_input_path(raw: &str) -> PathBuf {
16466 let trimmed = raw.trim();
16467 if trimmed.is_empty() {
16468 return workspace_root().join("samples").join("basic");
16469 }
16470
16471 let candidate = PathBuf::from(trimmed);
16472 let resolved = if candidate.is_absolute() {
16473 candidate
16474 } else {
16475 let rooted = workspace_root().join(&candidate);
16476 if rooted.exists() {
16477 rooted
16478 } else {
16479 workspace_root().join(candidate)
16480 }
16481 };
16482
16483 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16486 PathBuf::from(display_path(&canonical))
16487}
16488
16489fn dir_size_bytes(path: &Path) -> u64 {
16490 let mut total = 0u64;
16491 if let Ok(rd) = fs::read_dir(path) {
16492 for entry in rd.filter_map(Result::ok) {
16493 let p = entry.path();
16494 if p.is_file() {
16495 if let Ok(meta) = p.metadata() {
16496 total += meta.len();
16497 }
16498 } else if p.is_dir() {
16499 total += dir_size_bytes(&p);
16500 }
16501 }
16502 }
16503 total
16504}
16505
16506#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
16508 if bytes >= 1_073_741_824 {
16509 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16510 } else if bytes >= 1_048_576 {
16511 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16512 } else if bytes >= 1_024 {
16513 format!("{:.0} KB", bytes as f64 / 1_024.0)
16514 } else {
16515 format!("{bytes} B")
16516 }
16517}
16518
16519fn render_submodule_chips(
16520 root: &Path,
16521 submodules: &[(String, std::path::PathBuf)],
16522 out: &mut String,
16523) {
16524 use std::fmt::Write as _;
16525 let count = submodules.len();
16526 out.push_str(r#"<div class="submodule-preview-strip">"#);
16527 write!(
16528 out,
16529 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>"#,
16530 if count == 1 { "" } else { "s" }
16531 )
16532 .ok();
16533 out.push_str(r#"<div class="submodule-preview-chips">"#);
16534 for (sub_name, sub_rel_path) in submodules {
16535 let sub_abs = root.join(sub_rel_path);
16536 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16537 let mut sub_stats = PreviewStats::default();
16538 let mut sub_rows: Vec<PreviewRow> = Vec::new();
16539 let mut sub_langs: Vec<&'static str> = Vec::new();
16540 let mut sub_budget = PreviewBudget {
16541 shown: 0,
16542 max_entries: 2000,
16543 max_depth: 9,
16544 };
16545 let mut sub_next_id = 1usize;
16546 let _ = collect_preview_rows(
16547 &sub_abs,
16548 &sub_abs,
16549 0,
16550 None,
16551 &mut sub_next_id,
16552 &mut sub_budget,
16553 &mut sub_stats,
16554 &mut sub_rows,
16555 &mut sub_langs,
16556 &[],
16557 &[],
16558 );
16559 let stats_json = format!(
16560 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16561 sub_stats.directories,
16562 sub_stats.files,
16563 sub_stats.supported,
16564 sub_stats.skipped,
16565 sub_stats.unsupported
16566 );
16567 write!(
16568 out,
16569 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>"#,
16570 escape_html(sub_name),
16571 escape_html(&sub_rel_path.to_string_lossy()),
16572 escape_html(&sub_size),
16573 escape_html(&stats_json),
16574 escape_html(sub_name),
16575 escape_html(&sub_size),
16576 )
16577 .ok();
16578 }
16579 out.push_str(
16580 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
16581 );
16582 out.push_str(r"</div>");
16583}
16584
16585fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
16589 use std::fmt::Write as _;
16590 const MAX_LISTED: usize = 5;
16591 let total = layout.nested_repos.len();
16592
16593 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
16594 if layout.root_is_repo {
16595 write!(
16596 out,
16597 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>"#,
16598 if total == 1 { "repository" } else { "repositories" }
16599 )
16600 .ok();
16601 } else {
16602 write!(
16603 out,
16604 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>"#
16605 )
16606 .ok();
16607 }
16608
16609 out.push_str(r#"<div class="repo-pick-row">"#);
16610 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
16611 let abs = root.join(rel);
16612 let abs_display = display_path(&abs);
16613 let label = rel.to_string_lossy().replace('\\', "/");
16614 write!(
16615 out,
16616 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
16617 escape_html(&abs_display),
16618 escape_html(&label)
16619 )
16620 .ok();
16621 }
16622 if total > MAX_LISTED {
16623 write!(
16624 out,
16625 r#"<span class="repo-pick-more">and {} more</span>"#,
16626 total - MAX_LISTED
16627 )
16628 .ok();
16629 }
16630 out.push_str(r"</div>");
16631
16632 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
16633 out.push_str(r"</div>");
16634}
16635
16636fn render_language_pills_row(languages: &[&str], out: &mut String) {
16637 use std::fmt::Write as _;
16638 if languages.is_empty() {
16639 out.push_str(
16640 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16641 );
16642 return;
16643 }
16644 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16645 for language in languages {
16646 if let Some(icon) = language_icon_file(language) {
16647 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();
16648 } else if let Some(svg) = language_inline_svg(language) {
16649 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();
16650 } else {
16651 write!(
16652 out,
16653 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16654 escape_html(&language.to_ascii_lowercase()),
16655 escape_html(language)
16656 )
16657 .ok();
16658 }
16659 }
16660}
16661
16662#[allow(clippy::too_many_lines)]
16663fn build_preview_html(
16664 root: &Path,
16665 include_patterns: &[String],
16666 exclude_patterns: &[String],
16667) -> Result<String> {
16668 if !root.exists() {
16669 return Ok(format!(
16670 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16671 escape_html(&display_path(root))
16672 ));
16673 }
16674
16675 let _selected = display_path(root);
16676 let mut stats = PreviewStats::default();
16677 let mut rows = Vec::new();
16678 let mut languages = Vec::new();
16679 let mut budget = PreviewBudget {
16680 shown: 0,
16681 max_entries: 600,
16682 max_depth: 9,
16683 };
16684 let mut next_row_id = 1usize;
16685
16686 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16687 || root.to_string_lossy().into_owned(),
16688 std::string::ToString::to_string,
16689 );
16690 let root_modified = root
16691 .metadata()
16692 .ok()
16693 .and_then(|meta| meta.modified().ok())
16694 .map_or_else(|| "-".to_string(), format_system_time);
16695
16696 rows.push(PreviewRow {
16697 row_id: 0,
16698 parent_row_id: None,
16699 depth: 0,
16700 name: format!("{root_name}/"),
16701 kind: PreviewKind::Dir,
16702 is_dir: true,
16703 language: None,
16704 modified: root_modified,
16705 type_label: "Directory".to_string(),
16706 });
16707 collect_preview_rows(
16708 root,
16709 root,
16710 0,
16711 Some(0),
16712 &mut next_row_id,
16713 &mut budget,
16714 &mut stats,
16715 &mut rows,
16716 &mut languages,
16717 include_patterns,
16718 exclude_patterns,
16719 )?;
16720
16721 let root_size = format_dir_size(dir_size_bytes(root));
16722
16723 let mut out = String::new();
16724 write!(
16725 out,
16726 r#"<div class="explorer-wrap" data-project-size="{}">"#,
16727 escape_html(&root_size)
16728 )
16729 .ok();
16730 out.push_str(r#"<div class="explorer-toolbar compact">"#);
16731 out.push_str(r#"<div class="explorer-title-group">"#);
16732 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16733 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16734 out.push_str(r"</div></div>");
16735
16736 out.push_str(r#"<div class="scope-stats">"#);
16737 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();
16738 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();
16739 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();
16740 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();
16741 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();
16742 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>"#);
16743 out.push_str(r"</div>");
16744
16745 let submodules = sloc_core::detect_submodules(root);
16746 if !submodules.is_empty() {
16747 render_submodule_chips(root, &submodules, &mut out);
16748 }
16749
16750 let repo_layout = sloc_core::detect_repository_layout(root);
16751 if repo_layout.has_multiple_repos() {
16752 render_multi_repo_warning(root, &repo_layout, &mut out);
16753 }
16754
16755 out.push_str(r#"<div class="scope-info-row">"#);
16756 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16757 render_language_pills_row(&languages, &mut out);
16758 out.push_str(r"</div></div>");
16759 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>"#);
16760 out.push_str(r"</div>");
16761
16762 out.push_str(r#"<div class="file-explorer-shell">"#);
16763 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>"#);
16764 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>"#);
16765 out.push_str(r#"<div class="file-explorer-tree">"#);
16766 for row in rows {
16767 let status_label = row.kind.label();
16768 let lang_attr = row.language.unwrap_or("");
16769 let toggle_html = if row.is_dir {
16770 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16771 .to_string()
16772 } else {
16773 r#"<span class="tree-bullet">•</span>"#.to_string()
16774 };
16775 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();
16776 }
16777 if budget.shown >= budget.max_entries {
16778 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>"#);
16779 }
16780 out.push_str(r"</div></div></div>");
16781
16782 Ok(out)
16783}
16784
16785#[derive(Default)]
16786struct PreviewStats {
16787 directories: usize,
16788 files: usize,
16789 supported: usize,
16790 skipped: usize,
16791 unsupported: usize,
16792}
16793
16794struct PreviewRow {
16795 row_id: usize,
16796 parent_row_id: Option<usize>,
16797 depth: usize,
16798 name: String,
16799 kind: PreviewKind,
16800 is_dir: bool,
16801 language: Option<&'static str>,
16802 modified: String,
16803 type_label: String,
16804}
16805
16806#[derive(Copy, Clone)]
16807enum PreviewKind {
16808 Dir,
16809 Supported,
16810 Skipped,
16811 Unsupported,
16812}
16813
16814impl PreviewKind {
16815 const fn filter_key(self) -> &'static str {
16816 match self {
16817 Self::Dir => "dir",
16818 Self::Supported => "supported",
16819 Self::Skipped => "skipped",
16820 Self::Unsupported => "unsupported",
16821 }
16822 }
16823
16824 const fn label(self) -> &'static str {
16825 match self {
16826 Self::Dir => "dir",
16827 Self::Supported => "supported",
16828 Self::Skipped => "skipped by policy",
16829 Self::Unsupported => "unsupported",
16830 }
16831 }
16832
16833 const fn badge_class(self) -> &'static str {
16834 match self {
16835 Self::Dir => "badge badge-dir",
16836 Self::Supported => "badge badge-scan",
16837 Self::Skipped => "badge badge-skip",
16838 Self::Unsupported => "badge badge-unsupported",
16839 }
16840 }
16841
16842 const fn node_class(self) -> &'static str {
16843 match self {
16844 Self::Dir => "tree-node-dir",
16845 Self::Supported => "tree-node-supported",
16846 Self::Skipped => "tree-node-skipped",
16847 Self::Unsupported => "tree-node-unsupported",
16848 }
16849 }
16850}
16851
16852struct PreviewBudget {
16853 shown: usize,
16854 max_entries: usize,
16855 max_depth: usize,
16856}
16857
16858#[allow(clippy::too_many_arguments)]
16861fn handle_preview_dir_entry(
16862 root: &Path,
16863 path: &Path,
16864 name: &str,
16865 modified: String,
16866 depth: usize,
16867 parent_row_id: Option<usize>,
16868 row_id: usize,
16869 next_row_id: &mut usize,
16870 budget: &mut PreviewBudget,
16871 stats: &mut PreviewStats,
16872 rows: &mut Vec<PreviewRow>,
16873 languages: &mut Vec<&'static str>,
16874 include_patterns: &[String],
16875 exclude_patterns: &[String],
16876) -> Result<()> {
16877 let relative = preview_relative_path(root, path);
16878 if should_skip_preview_directory(&relative, exclude_patterns) {
16879 return Ok(());
16880 }
16881 stats.directories += 1;
16882 rows.push(PreviewRow {
16883 row_id,
16884 parent_row_id,
16885 depth: depth + 1,
16886 name: format!("{name}/"),
16887 kind: PreviewKind::Dir,
16888 is_dir: true,
16889 language: None,
16890 modified,
16891 type_label: "Directory".to_string(),
16892 });
16893 budget.shown += 1;
16894 if !matches!(name, ".git" | "node_modules" | "target") {
16895 collect_preview_rows(
16896 root,
16897 path,
16898 depth + 1,
16899 Some(row_id),
16900 next_row_id,
16901 budget,
16902 stats,
16903 rows,
16904 languages,
16905 include_patterns,
16906 exclude_patterns,
16907 )?;
16908 }
16909 Ok(())
16910}
16911
16912#[allow(clippy::too_many_arguments)]
16914fn handle_preview_file_entry(
16915 root: &Path,
16916 path: &Path,
16917 name: &str,
16918 modified: String,
16919 depth: usize,
16920 parent_row_id: Option<usize>,
16921 row_id: usize,
16922 budget: &mut PreviewBudget,
16923 stats: &mut PreviewStats,
16924 rows: &mut Vec<PreviewRow>,
16925 languages: &mut Vec<&'static str>,
16926 include_patterns: &[String],
16927 exclude_patterns: &[String],
16928) {
16929 let relative = preview_relative_path(root, path);
16930 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16931 return;
16932 }
16933 stats.files += 1;
16934 let kind = classify_preview_file(name);
16935 match kind {
16936 PreviewKind::Supported => stats.supported += 1,
16937 PreviewKind::Skipped => stats.skipped += 1,
16938 PreviewKind::Unsupported => stats.unsupported += 1,
16939 PreviewKind::Dir => {}
16940 }
16941 let language = detect_language_name(name);
16942 if let Some(lang) = language {
16943 if !languages.contains(&lang) {
16944 languages.push(lang);
16945 }
16946 }
16947 rows.push(PreviewRow {
16948 row_id,
16949 parent_row_id,
16950 depth: depth + 1,
16951 name: name.to_owned(),
16952 kind,
16953 is_dir: false,
16954 language,
16955 modified,
16956 type_label: preview_type_label(name, language, kind),
16957 });
16958 budget.shown += 1;
16959}
16960
16961#[allow(clippy::too_many_arguments)]
16962#[allow(clippy::too_many_lines)]
16963fn collect_preview_rows(
16964 root: &Path,
16965 dir: &Path,
16966 depth: usize,
16967 parent_row_id: Option<usize>,
16968 next_row_id: &mut usize,
16969 budget: &mut PreviewBudget,
16970 stats: &mut PreviewStats,
16971 rows: &mut Vec<PreviewRow>,
16972 languages: &mut Vec<&'static str>,
16973 include_patterns: &[String],
16974 exclude_patterns: &[String],
16975) -> Result<()> {
16976 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16977 return Ok(());
16978 }
16979
16980 let mut entries = fs::read_dir(dir)
16981 .with_context(|| format!("failed to read directory {}", dir.display()))?
16982 .filter_map(std::result::Result::ok)
16983 .collect::<Vec<_>>();
16984 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16985
16986 for entry in entries {
16987 if budget.shown >= budget.max_entries {
16988 break;
16989 }
16990
16991 let path = entry.path();
16992 let name = entry.file_name().to_string_lossy().into_owned();
16993 let Ok(metadata) = entry.metadata() else {
16994 continue;
16995 };
16996 let row_id = *next_row_id;
16997 *next_row_id += 1;
16998 let modified = metadata
16999 .modified()
17000 .ok()
17001 .map_or_else(|| "-".to_string(), format_system_time);
17002
17003 if metadata.is_dir() {
17004 handle_preview_dir_entry(
17005 root,
17006 &path,
17007 &name,
17008 modified,
17009 depth,
17010 parent_row_id,
17011 row_id,
17012 next_row_id,
17013 budget,
17014 stats,
17015 rows,
17016 languages,
17017 include_patterns,
17018 exclude_patterns,
17019 )?;
17020 continue;
17021 }
17022
17023 if metadata.is_file() {
17024 handle_preview_file_entry(
17025 root,
17026 &path,
17027 &name,
17028 modified,
17029 depth,
17030 parent_row_id,
17031 row_id,
17032 budget,
17033 stats,
17034 rows,
17035 languages,
17036 include_patterns,
17037 exclude_patterns,
17038 );
17039 }
17040 }
17041
17042 Ok(())
17043}
17044
17045fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17046 if let Some(language) = language {
17047 return format!("{language} source");
17048 }
17049 let lower = name.to_ascii_lowercase();
17050 let ext = Path::new(&lower)
17051 .extension()
17052 .and_then(|e| e.to_str())
17053 .unwrap_or("");
17054 match kind {
17055 PreviewKind::Skipped => {
17056 if lower.ends_with(".min.js") {
17057 "Minified asset".to_string()
17058 } else if [
17059 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17060 ]
17061 .contains(&ext)
17062 {
17063 "Binary or archive".to_string()
17064 } else {
17065 "Skipped file".to_string()
17066 }
17067 }
17068 PreviewKind::Unsupported => {
17069 if ext.is_empty() {
17070 "Unsupported file".to_string()
17071 } else {
17072 format!("{} file", ext.to_ascii_uppercase())
17073 }
17074 }
17075 PreviewKind::Supported => "Supported source".to_string(),
17076 PreviewKind::Dir => "Directory".to_string(),
17077 }
17078}
17079
17080fn format_system_time(time: SystemTime) -> String {
17081 #[allow(clippy::cast_possible_wrap)]
17082 let secs = match time.duration_since(UNIX_EPOCH) {
17083 Ok(duration) => duration.as_secs() as i64,
17084 Err(_) => return "-".to_string(),
17085 };
17086 let days = secs.div_euclid(86_400);
17087 let secs_of_day = secs.rem_euclid(86_400);
17088 let (year, month, day) = civil_from_days(days);
17089 let hour = secs_of_day / 3_600;
17090 let minute = (secs_of_day % 3_600) / 60;
17091 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17092}
17093
17094#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17095fn civil_from_days(days: i64) -> (i32, u32, u32) {
17096 let z = days + 719_468;
17097 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17098 let doe = z - era * 146_097;
17099 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17100 let y = yoe + era * 400;
17101 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17102 let mp = (5 * doy + 2) / 153;
17103 let d = doy - (153 * mp + 2) / 5 + 1;
17104 let m = mp + if mp < 10 { 3 } else { -9 };
17105 let year = y + i64::from(m <= 2);
17106 (year as i32, m as u32, d as u32)
17107}
17108
17109#[allow(clippy::case_sensitive_file_extension_comparisons)]
17112fn detect_language_name(name: &str) -> Option<&'static str> {
17113 let lower = name.to_ascii_lowercase();
17114 if lower.ends_with(".c") || lower.ends_with(".h") {
17115 Some("C")
17116 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17117 .iter()
17118 .any(|s| lower.ends_with(s))
17119 {
17120 Some("C++")
17121 } else if lower.ends_with(".cs") {
17122 Some("C#")
17123 } else if lower.ends_with(".py") {
17124 Some("Python")
17125 } else if lower.ends_with(".sh") {
17126 Some("Shell")
17127 } else if [".ps1", ".psm1", ".psd1"]
17128 .iter()
17129 .any(|s| lower.ends_with(s))
17130 {
17131 Some("PowerShell")
17132 } else {
17133 None
17134 }
17135}
17136
17137fn language_icon_file(language: &str) -> Option<&'static str> {
17138 match language {
17139 "C" => Some("c.png"),
17140 "C++" => Some("cpp.png"),
17141 "C#" => Some("c-sharp.png"),
17142 "Python" => Some("python.png"),
17143 "Shell" => Some("shell.png"),
17144 "PowerShell" => Some("powershell.png"),
17145 "JavaScript" => Some("java-script.png"),
17146 "HTML" => Some("html-5.png"),
17147 "Java" => Some("java.png"),
17148 "Visual Basic" => Some("visual-basic.png"),
17149 "Assembly" => Some("asm.png"),
17150 "Go" => Some("go.png"),
17151 "R" => Some("r.png"),
17152 "XML" => Some("xml.png"),
17153 "Groovy" => Some("groovy.png"),
17154 "Dockerfile" => Some("docker.png"),
17155 "Makefile" => Some("makefile.svg"),
17156 "Perl" => Some("perl.svg"),
17157 _ => None,
17158 }
17159}
17160
17161fn language_inline_svg(language: &str) -> Option<&'static str> {
17166 match language {
17167 "Rust" => Some(
17168 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>"##,
17169 ),
17170 "TypeScript" => Some(
17171 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>"##,
17172 ),
17173 _ => None,
17174 }
17175}
17176
17177#[allow(clippy::case_sensitive_file_extension_comparisons)]
17180fn classify_preview_file(name: &str) -> PreviewKind {
17181 let lower = name.to_ascii_lowercase();
17182
17183 let scannable = [
17184 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17185 ".psm1", ".psd1",
17186 ]
17187 .iter()
17188 .any(|suffix| lower.ends_with(suffix));
17189
17190 if scannable {
17191 PreviewKind::Supported
17192 } else if lower.ends_with(".min.js")
17193 || lower.ends_with(".lock")
17194 || lower.ends_with(".png")
17195 || lower.ends_with(".jpg")
17196 || lower.ends_with(".jpeg")
17197 || lower.ends_with(".gif")
17198 || lower.ends_with(".zip")
17199 || lower.ends_with(".pdf")
17200 || lower.ends_with(".pyc")
17201 || lower.ends_with(".xz")
17202 || lower.ends_with(".tar")
17203 || lower.ends_with(".gz")
17204 {
17205 PreviewKind::Skipped
17206 } else {
17207 PreviewKind::Unsupported
17208 }
17209}
17210
17211fn preview_relative_path(root: &Path, path: &Path) -> String {
17212 path.strip_prefix(root)
17213 .ok()
17214 .unwrap_or(path)
17215 .to_string_lossy()
17216 .replace('\\', "/")
17217 .trim_matches('/')
17218 .to_string()
17219}
17220
17221fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17222 if relative.is_empty() {
17223 return false;
17224 }
17225
17226 exclude_patterns.iter().any(|pattern| {
17227 wildcard_match(pattern, relative)
17228 || wildcard_match(pattern, &format!("{relative}/"))
17229 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17230 })
17231}
17232
17233fn should_include_preview_file(
17234 relative: &str,
17235 include_patterns: &[String],
17236 exclude_patterns: &[String],
17237) -> bool {
17238 if relative.is_empty() {
17239 return true;
17240 }
17241
17242 let included = include_patterns.is_empty()
17243 || include_patterns
17244 .iter()
17245 .any(|pattern| wildcard_match(pattern, relative));
17246 let excluded = exclude_patterns
17247 .iter()
17248 .any(|pattern| wildcard_match(pattern, relative));
17249
17250 included && !excluded
17251}
17252
17253fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17254 let pattern = pattern.trim().replace('\\', "/");
17255 let candidate = candidate.trim().replace('\\', "/");
17256 let p = pattern.as_bytes();
17257 let c = candidate.as_bytes();
17258 let mut pi = 0usize;
17259 let mut ci = 0usize;
17260 let mut star: Option<usize> = None;
17261 let mut star_match = 0usize;
17262
17263 while ci < c.len() {
17264 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17265 pi += 1;
17266 ci += 1;
17267 } else if pi < p.len() && p[pi] == b'*' {
17268 while pi < p.len() && p[pi] == b'*' {
17269 pi += 1;
17270 }
17271 star = Some(pi);
17272 star_match = ci;
17273 } else if let Some(star_pi) = star {
17274 star_match += 1;
17275 ci = star_match;
17276 pi = star_pi;
17277 } else {
17278 return false;
17279 }
17280 }
17281
17282 while pi < p.len() && p[pi] == b'*' {
17283 pi += 1;
17284 }
17285
17286 pi == p.len()
17287}
17288
17289fn escape_html(value: &str) -> String {
17290 value
17291 .replace('&', "&")
17292 .replace('<', "<")
17293 .replace('>', ">")
17294 .replace('"', """)
17295 .replace('\'', "'")
17296}
17297
17298#[derive(Clone)]
17299struct SubmoduleRow {
17300 name: String,
17301 relative_path: String,
17302 files_analyzed: u64,
17303 code_lines: u64,
17304 comment_lines: u64,
17305 blank_lines: u64,
17306 total_physical_lines: u64,
17307 html_url: Option<String>,
17308}
17309
17310#[derive(Template)]
17311#[template(
17312 source = r##"
17313<!doctype html>
17314<html lang="en">
17315<head>
17316 <meta charset="utf-8">
17317 <title>OxideSLOC | tmp-sloc</title>
17318 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17319 <style nonce="{{ csp_nonce }}">
17320 :root {
17321 --bg: #efe9e2;
17322 --surface: #fcfaf7;
17323 --surface-2: #f7f0e8;
17324 --surface-3: #efe3d5;
17325 --line: #dfcfbf;
17326 --line-strong: #cfb29c;
17327 --text: #2f241c;
17328 --muted: #6f6257;
17329 --muted-2: #917f71;
17330 --nav: #b85d33;
17331 --nav-2: #7a371b;
17332 --accent: #2563eb;
17333 --accent-2: #1d4ed8;
17334 --oxide: #b85d33;
17335 --oxide-2: #8f4220;
17336 --success-bg: #eaf9ee;
17337 --success-text: #1c8746;
17338 --warn-bg: #fff2d8;
17339 --warn-text: #926000;
17340 --danger-bg: #fdeaea;
17341 --danger-text: #b33b3b;
17342 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17343 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17344 --radius: 14px;
17345 }
17346
17347 body.dark-theme {
17348 --bg: #1b1511;
17349 --surface: #261c17;
17350 --surface-2: #2d221d;
17351 --surface-3: #372922;
17352 --line: #524238;
17353 --line-strong: #6c5649;
17354 --text: #f5ece6;
17355 --muted: #c7b7aa;
17356 --muted-2: #aa9485;
17357 --nav: #b85d33;
17358 --nav-2: #7a371b;
17359 --accent: #6f9bff;
17360 --accent-2: #4a78ee;
17361 --oxide: #d37a4c;
17362 --oxide-2: #b35428;
17363 --success-bg: #163927;
17364 --success-text: #8fe2a8;
17365 --warn-bg: #3c2d11;
17366 --warn-text: #f3cb75;
17367 --danger-bg: #3d1f1f;
17368 --danger-text: #ff9f9f;
17369 --shadow: 0 14px 28px rgba(0,0,0,0.28);
17370 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17371 }
17372
17373 * { box-sizing: border-box; }
17374 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); }
17375 html { overflow-y: scroll; }
17376 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17377 .top-nav, .page, .loading { position: relative; z-index: 2; }
17378 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17379 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17380 .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); }
17381 .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; }
17382 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17383 .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)); }
17384 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17385 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17386 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17387 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17388 .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; }
17389 .nav-project-pill.visible { display:inline-flex; }
17390 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17391 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17392 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17393 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17394 @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; } }
17395 .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; }
17396 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17397 .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; }
17398 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17399 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17400 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17401 .theme-toggle .icon-sun { display:none; }
17402 body.dark-theme .theme-toggle .icon-sun { display:block; }
17403 body.dark-theme .theme-toggle .icon-moon { display:none; }
17404 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
17405 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17406 .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);}
17407 .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;}
17408 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17409 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17410 .settings-modal-body{padding:14px 16px 16px;}
17411 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17412 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17413 .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;}
17414 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17415 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17416 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17417 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17418 .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;}
17419 .tz-select:focus{border-color:var(--oxide);}
17420 .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; }
17421 .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;}
17422 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17423 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17424 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17425 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17426 .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; }
17427 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17428 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17429 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17430 .wb-stats-header { padding: 10px 24px 0; }
17431 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17432 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17433 .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; }
17434 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17435 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17436 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17437 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17438 .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; }
17439 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17440 .ws-stat-analyzers { position: relative; }
17441 .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; }
17442 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17443 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17444 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17445 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17446 .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; }
17447 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17448 .ws-divider { display: none; }
17449 .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%; }
17450 .ws-path-link:hover { color:var(--oxide); }
17451 body.dark-theme .ws-path-link { color:var(--oxide); }
17452 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17453 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17454 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17455 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17456 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17457 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17458 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17459 .ws-mini-box-lg { flex:2 1 0; }
17460 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17461 .ws-mini-box-br { flex:1.5 1 0; }
17462 .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; }
17463 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17464 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17465 #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; }
17466 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17467 .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; }
17468 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17469 .git-source-banner strong { font-weight:800; color:var(--text); }
17470 .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; }
17471 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17472 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17473 .git-source-banner a:hover { text-decoration:underline; }
17474 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17475 .path-scope-sep { background:var(--line); margin:4px 14px; }
17476 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17477 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17478 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17479 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17480 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17481 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17482 .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; }
17483 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17484 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17485 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17486 .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; }
17487 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17488 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17489 [data-wb-tip] { cursor:help; }
17490 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17491 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17492 .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; }
17493 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17494 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17495 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17496 .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; }
17497 .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); }
17498 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17499 .side-info-card { padding: 18px; }
17500 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17501 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17502 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17503 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17504 .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); }
17505 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17506 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17507 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17508 .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; }
17509 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17510 .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; }
17511 .side-stack::-webkit-scrollbar { display: none; }
17512 .step-nav { padding: 20px 16px; }
17513 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17514 .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; }
17515 .step-button:hover { background: var(--surface-2); }
17516 .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); }
17517 .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; }
17518 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17519 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17520 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17521 .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); }
17522 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17523 .step-nav-sum-row:last-child { border-bottom:none; }
17524 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17525 .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; }
17526 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17527 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17528 .quick-scan-section { padding: 10px 4px 14px; }
17529 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17530 .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; }
17531 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17532 .quick-scan-btn:active { transform:translateY(0); }
17533 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17534 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17535 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17536 @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);} }
17537 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17538 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17539 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17540 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17541 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17542 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17543 .step-button.done .step-check { opacity:1; }
17544 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17545 .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; }
17546 .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; }
17547 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17548 .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; }
17549 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17550 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17551 .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; }
17552 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17553 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17554 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17555 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17556 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17557 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17558 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17559 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17560 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17561 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17562 .card-body { padding: 22px; }
17563 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17564 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17565 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17566 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17567 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17568 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17569 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17570 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17571 .field { min-width:0; }
17572 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17573 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; }
17574 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); }
17575 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17576 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); }
17577 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17578 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17579 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17580 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17581 .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; }
17582 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17583 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17584 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17585 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17586 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17587 .input-group.compact { grid-template-columns: 1fr auto auto; }
17588 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17589 .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)); }
17590 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17591 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17592 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17593 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17594 .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; }
17595 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17596 .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; }
17597 .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); }
17598 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17599 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17600 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17601 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17602 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17603 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17604 @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; } }
17605 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17606 button.secondary { background: var(--surface); }
17607 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17608 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17609 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17610 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17611 .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); }
17612 .section + .wizard-actions { border-top: none; padding-top: 0; }
17613 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17614 .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; }
17615 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17616 .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; }
17617 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17618 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17619 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17620 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17621 .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); }
17622 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17623 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17624 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17625 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17626 .field-help-grid.coupled-help { margin-top: 12px; }
17627 .field-help-grid.preset-grid { align-items: start; }
17628 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17629 .preset-inline-row .field { margin: 0; }
17630 .preset-inline-row .explainer-card { margin: 0; }
17631 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17632 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17633 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17634 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17635 .preset-kv-row > :last-child { flex:1; min-width:0; }
17636 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17637 .output-field-row .field { margin: 0; }
17638 .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; }
17639 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17640 .step3-subtitle { margin-bottom: 10px; max-width: none; }
17641 .counting-intro { margin-bottom: 8px; max-width: none; }
17642 .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; }
17643 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17644 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17645 .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; }
17646 .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; }
17647 .section-spacer-top { margin-top: 28px; }
17648 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17649 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17650 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17651 .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); }
17652 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17653 .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; }
17654 .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; }
17655 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17656 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17657 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17658 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17659 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17660 .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; }
17661 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17662 .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); }
17663 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; }
17664 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; }
17665 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17666 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17667 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17668 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17669 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17670 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17671 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17672 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17673 .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); }
17674 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17675 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17676 .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; }
17677 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17678 .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; }
17679 .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; }
17680 .always-tracked-tip-body { flex:1; min-width:0; }
17681 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17682 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17683 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17684 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17685 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17686 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17687 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17688 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17689 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17690 .advanced-rule-description strong { color: var(--text); }
17691 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17692 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17693 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17694 .review-link:hover { text-decoration: underline; }
17695 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17696 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17697 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17698 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17699 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17700 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17701 .review-card ul { padding-left: 18px; margin: 0; }
17702 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17703 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17704 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17705 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17706 .review-card { min-height: 0; }
17707 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17708 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17709 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17710 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17711 .lang-overflow-chip { position:relative; cursor:default; }
17712 .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; }
17713 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17714 .git-inline-row { align-items:start; }
17715 .mixed-line-card { display:flex; flex-direction:column; }
17716 .preset-inline-row .toggle-card { justify-content: center; }
17717 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17718 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17719 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17720 .explorer-title { font-size: 18px; font-weight: 850; }
17721 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17722 .explorer-subtitle.wide { max-width: none; }
17723 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17724 .better-spacing { align-items:flex-start; justify-content:flex-end; }
17725 .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; }
17726 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17727 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17728 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17729 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17730 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17731 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17732 .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; }
17733 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17734 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17735 .scope-stat-button.supported { background: var(--success-bg); }
17736 .scope-stat-button.skipped { background: var(--warn-bg); }
17737 .scope-stat-button.unsupported { background: var(--danger-bg); }
17738 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17739 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17740 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17741 [data-tooltip] { position: relative; }
17742 [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); }
17743 [data-tooltip]:hover::after { display: block; }
17744 .scope-stat-button[data-tooltip] { cursor: pointer; }
17745 .badge[data-tooltip] { cursor: help; }
17746 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17747 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17748 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17749 .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; }
17750 .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; }
17751 code { display:inline-block; margin-top:0; padding:2px 7px; }
17752 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17753 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17754 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17755 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17756 .language-pill.muted-pill { color: var(--muted); }
17757 button.language-pill { appearance:none; cursor:pointer; }
17758 .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); }
17759 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17760 .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; }
17761 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17762 .file-explorer-search-row { margin-left: auto; }
17763 .explorer-filter-select { min-width: 170px; width: 170px; }
17764 .explorer-search { min-width: 300px; width: 300px; }
17765 .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); }
17766 .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; }
17767 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17768 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17769 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17770 .file-explorer-tree { max-height: 640px; overflow:auto; }
17771 .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); }
17772 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17773 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17774 .tree-row.hidden-by-filter { display:none !important; }
17775 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17776 .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; }
17777 .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; }
17778 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17779 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17780 .tree-node { display:inline-flex; align-items:center; min-width:0; }
17781 .tree-node-dir { color: var(--text); font-weight: 800; }
17782 .tree-node-supported { color: var(--success-text); }
17783 .tree-node-skipped { color: var(--warn-text); }
17784 .tree-node-unsupported { color: var(--danger-text); }
17785 .tree-node-more { color: var(--muted-2); font-style: italic; }
17786 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17787 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17788 .tree-status-cell { display:flex; justify-content:flex-start; }
17789 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17790 .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; }
17791 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
17792 .preview-warning p { margin: 0 0 10px; }
17793 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
17794 .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; }
17795 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
17796 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
17797 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
17798 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
17799 .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; }
17800 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17801 .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; }
17802 @keyframes prevSpin { to { transform:rotate(360deg); } }
17803 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17804 .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; }
17805 .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; }
17806 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17807 .preview-gate-info svg { width:16px; height:16px; }
17808 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17809 @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); } }
17810 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17811 .preview-loading-text { flex:1; min-width:0; }
17812 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17813 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17814 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17815 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17816 .cov-scan-idle { display:none; }
17817 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17818 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17819 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17820 .cov-scan-title { font-weight:600; font-size:12.5px; }
17821 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17822 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17823 .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; }
17824 .cov-scan-use:hover { opacity:.75; }
17825 .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; }
17826 .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; }
17827 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17828 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17829 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17830 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17831 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17832 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17833 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17834 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17835 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17836 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17837 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17838 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17839 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17840 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17841 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17842 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17843 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17844 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17845 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17846 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17847 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17848 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17849 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17850 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17851 .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); }
17852 .loading.active { display:flex; }
17853 /* Lock page scroll while the analysis modal is open so the removed scrollbar
17854 gutter doesn't pull the centered card slightly left of true center. */
17855 body.modal-open { overflow: hidden; }
17856 .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; }
17857 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17858 .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; }
17859 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17860 .loading-card > * { position:relative; z-index:1; }
17861 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17862 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%); }
17863 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17864 .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; }
17865 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17866 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17867 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17868 .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; }
17869 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17870 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17871 .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; }
17872 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17873 .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; }
17874 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17875 .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; }
17876 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17877 .lc-step.done { color:var(--muted);opacity:0.55; }
17878 .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; }
17879 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17880 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17881 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17882 .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; }
17883 .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; }
17884 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17885 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17886 .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; }
17887 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17888 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17889 .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; }
17890 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17891 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17892 .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; }
17893 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17894 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17895 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17896 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17897 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17898 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17899 .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; }
17900 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17901 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17902 .hidden { display:none !important; }
17903 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17904 .site-footer a{color:var(--muted);}
17905 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17906 @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; } }
17907 .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;}
17908 @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));}}
17909 .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;}
17910 .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; }
17911 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17912 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17913 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17914 .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; }
17915 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17916 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17917 .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; }
17918 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17919 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17920 .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; }
17921 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17922 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17923 .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; }
17924 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17925 .info-icon-btn:hover { color:var(--text); }
17926 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); }
17927 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17928 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17929 .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;}
17930 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17931 .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;}
17932 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17933 #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);}
17934 #offline-file-banner.show{display:flex;}
17935 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17936 #offline-file-banner .ofb-text{flex:1;}
17937 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17938 #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;}
17939 #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;}
17940 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17941 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17942 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17943 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17944 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17945 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17946 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17947 </style>
17948</head>
17949<body id="page-top">
17950 <div id="offline-file-banner" role="alert">
17951 <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>
17952 <span class="ofb-text">
17953 Charts, images, and navigation require the oxide-sloc server.
17954 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17955 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17956 The metric tables below are fully readable without the server.
17957 </span>
17958 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17959 </div>
17960 <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>
17961 <div class="background-watermarks" aria-hidden="true">
17962 <img src="/images/logo/logo-text.png" alt="" />
17963 <img src="/images/logo/logo-text.png" alt="" />
17964 <img src="/images/logo/logo-text.png" alt="" />
17965 <img src="/images/logo/logo-text.png" alt="" />
17966 <img src="/images/logo/logo-text.png" alt="" />
17967 <img src="/images/logo/logo-text.png" alt="" />
17968 <img src="/images/logo/logo-text.png" alt="" />
17969 <img src="/images/logo/logo-text.png" alt="" />
17970 <img src="/images/logo/logo-text.png" alt="" />
17971 <img src="/images/logo/logo-text.png" alt="" />
17972 <img src="/images/logo/logo-text.png" alt="" />
17973 <img src="/images/logo/logo-text.png" alt="" />
17974 <img src="/images/logo/logo-text.png" alt="" />
17975 <img src="/images/logo/logo-text.png" alt="" />
17976 </div>
17977 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17978 <div class="top-nav">
17979 <div class="top-nav-inner">
17980 <a class="brand" href="/">
17981 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17982 <div class="brand-copy">
17983 <div class="brand-title">OxideSLOC</div>
17984 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17985 </div>
17986 </a>
17987 <div class="nav-project-slot">
17988 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17989 <span class="nav-project-label">Project</span>
17990 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17991 </div>
17992 </div>
17993 <div class="nav-status">
17994 <a class="nav-pill" href="/">Home</a>
17995 <div class="nav-dropdown">
17996 <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>
17997 <div class="nav-dropdown-menu">
17998 <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>
17999 </div>
18000 </div>
18001 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18002 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18003 <div class="nav-dropdown">
18004 <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>
18005 <div class="nav-dropdown-menu">
18006 <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>
18007 </div>
18008 </div>
18009 <div class="server-status-wrap" id="server-status-wrap">
18010 <div class="nav-pill server-online-pill" id="server-status-pill">
18011 <span class="status-dot" id="status-dot"></span>
18012 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18013 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18014 </div>
18015 <div class="server-status-tip">
18016 {% if server_mode %}
18017 OxideSLOC is running in server mode — accessible on your LAN.
18018 {% else %}
18019 OxideSLOC is running locally — only accessible from this machine.
18020 {% endif %}
18021 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18022 </div>
18023 </div>
18024 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18025 <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>
18026 </button>
18027 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18028 <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>
18029 <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>
18030 </button>
18031 </div>
18032 </div>
18033 </div>
18034
18035 <div class="loading" id="loading">
18036 <div class="loading-card" id="loading-card">
18037 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18038 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18039 <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>
18040 <div class="lc-steps" id="lc-steps">
18041 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18042 <div class="lc-step-arrow">›</div>
18043 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18044 <div class="lc-step-arrow">›</div>
18045 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18046 <div class="lc-step-arrow">›</div>
18047 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18048 </div>
18049 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18050 <div class="lc-metrics" id="lc-metrics">
18051 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18052 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18053 <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>
18054 <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>
18055 </div>
18056 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18057 <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>
18058 <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>
18059 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18060 <div class="lc-actions hidden" id="lc-actions">
18061 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18062 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18063 </div>
18064 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18065 <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>
18066 Cancel scan
18067 </button>
18068 </div>
18069 </div>
18070
18071 <div class="page">
18072 <div class="workbench-strip">
18073 <div class="workbench-box wb-stats">
18074 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18075 <span class="wb-stats-title">Analysis session</span>
18076 </div>
18077 <div class="ws-left">
18078 <div class="ws-stat ws-stat-analyzers">
18079 <span class="ws-label">Analyzers</span>
18080 <span class="ws-value">
18081 <span class="ws-badge">60 languages</span>
18082 </span>
18083 <div class="ws-lang-tooltip">
18084 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18085 <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>
18086 <div class="ws-lang-grid">
18087 <span class="ws-lang-item">Assembly</span>
18088 <span class="ws-lang-item">C</span>
18089 <span class="ws-lang-item">C++</span>
18090 <span class="ws-lang-item">C#</span>
18091 <span class="ws-lang-item">Clojure</span>
18092 <span class="ws-lang-item">CSS</span>
18093 <span class="ws-lang-item">Dart</span>
18094 <span class="ws-lang-item">Dockerfile</span>
18095 <span class="ws-lang-item">Elixir</span>
18096 <span class="ws-lang-item">Erlang</span>
18097 <span class="ws-lang-item">F#</span>
18098 <span class="ws-lang-item">Go</span>
18099 <span class="ws-lang-item">Groovy</span>
18100 <span class="ws-lang-item">Haskell</span>
18101 <span class="ws-lang-item">HTML</span>
18102 <span class="ws-lang-item">Java</span>
18103 <span class="ws-lang-item">JavaScript</span>
18104 <span class="ws-lang-item">Julia</span>
18105 <span class="ws-lang-item">Kotlin</span>
18106 <span class="ws-lang-item">Lua</span>
18107 <span class="ws-lang-item">Makefile</span>
18108 <span class="ws-lang-item">Nim</span>
18109 <span class="ws-lang-item">Obj-C</span>
18110 <span class="ws-lang-item">OCaml</span>
18111 <span class="ws-lang-item">Perl</span>
18112 <span class="ws-lang-item">PHP</span>
18113 <span class="ws-lang-item">PowerShell</span>
18114 <span class="ws-lang-item">Python</span>
18115 <span class="ws-lang-item">R</span>
18116 <span class="ws-lang-item">Ruby</span>
18117 <span class="ws-lang-item">Rust</span>
18118 <span class="ws-lang-item">Scala</span>
18119 <span class="ws-lang-item">SCSS</span>
18120 <span class="ws-lang-item">Shell</span>
18121 <span class="ws-lang-item">SQL</span>
18122 <span class="ws-lang-item">Svelte</span>
18123 <span class="ws-lang-item">Swift</span>
18124 <span class="ws-lang-item">TypeScript</span>
18125 <span class="ws-lang-item">Vue</span>
18126 <span class="ws-lang-item">XML</span>
18127 <span class="ws-lang-item">Zig</span>
18128 <span class="ws-lang-item">Solidity</span>
18129 <span class="ws-lang-item">Protobuf</span>
18130 <span class="ws-lang-item">HCL</span>
18131 <span class="ws-lang-item">GraphQL</span>
18132 <span class="ws-lang-item">Ada</span>
18133 <span class="ws-lang-item">VHDL</span>
18134 <span class="ws-lang-item">Verilog</span>
18135 <span class="ws-lang-item">Tcl</span>
18136 <span class="ws-lang-item">Pascal</span>
18137 <span class="ws-lang-item">Visual Basic</span>
18138 <span class="ws-lang-item">Lisp</span>
18139 <span class="ws-lang-item">Fortran</span>
18140 <span class="ws-lang-item">Nix</span>
18141 <span class="ws-lang-item">Crystal</span>
18142 <span class="ws-lang-item">D</span>
18143 <span class="ws-lang-item">GLSL</span>
18144 <span class="ws-lang-item">CMake</span>
18145 <span class="ws-lang-item">Elm</span>
18146 <span class="ws-lang-item">Awk</span>
18147 </div>
18148 </div>
18149 </div>
18150 <div class="ws-divider"></div>
18151 <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>
18152 <div class="ws-divider"></div>
18153 <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.">
18154 <span class="ws-label">Output</span>
18155 <span class="ws-value">
18156 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18157 <span id="ws-output-root">project/sloc</span>
18158 </button>
18159 </span>
18160 </div>
18161 </div>
18162 </div>
18163 <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.">
18164 <div class="ws-history-label">Scan history</div>
18165 <div class="ws-history-inner">
18166 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18167 <div class="ws-mini-label">Scans</div>
18168 <div class="ws-mini-value" id="ws-scan-count">—</div>
18169 </div>
18170 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18171 <div class="ws-mini-label">Last Scan</div>
18172 <div class="ws-mini-value" id="ws-last-scan">—</div>
18173 </div>
18174 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18175 <div class="ws-mini-label">Branch</div>
18176 <div class="ws-mini-value" id="ws-branch">—</div>
18177 </div>
18178 </div>
18179 </div>
18180 </div>
18181
18182 <div class="layout">
18183 <aside class="side-stack">
18184 <section class="step-nav">
18185 <h3>Guided scan setup</h3>
18186 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18187 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18188 Top of page
18189 </a>
18190 <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>
18191 <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>
18192 <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>
18193 <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>
18194
18195 <div class="step-steps-divider"></div>
18196
18197 <div class="step-nav-info" id="step-nav-info">
18198 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18199 <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>
18200 </div>
18201
18202 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18203 <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>
18204 <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>
18205 <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>
18206 </div>
18207
18208 <div class="quick-scan-divider"></div>
18209 <div class="quick-scan-section">
18210 <div class="quick-scan-label">No customization needed?</div>
18211 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18212 <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>
18213 Quick Scan
18214 </button>
18215 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18216 </div>
18217
18218 <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>
18219 <div class="sidebar-scroll-divider"></div>
18220 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18221 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18222 Skip to bottom
18223 </a>
18224 </section>
18225
18226 </aside>
18227
18228 <section class="card">
18229 <div class="card-header">
18230 <div class="card-title-row">
18231 <div>
18232 <h1 class="card-title">Guided scan configuration</h1>
18233 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18234 </div>
18235 <div class="wizard-progress" aria-label="Scan setup progress">
18236 <div class="wizard-progress-top">
18237 <span class="wizard-progress-label">Setup progress</span>
18238 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18239 </div>
18240 <div class="wizard-progress-track">
18241 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18242 </div>
18243 </div>
18244 </div>
18245 </div>
18246 <div class="card-body">
18247 <form method="post" action="/analyze" id="analyze-form">
18248 <div class="wizard-step active" data-step="1">
18249 <div class="section">
18250 <div class="section-kicker">Step 1</div>
18251 <h2>Select project and preview scope</h2>
18252 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18253 <div class="field">
18254 <label for="path">Project path</label>
18255 {% if !git_repo.is_empty() %}
18256 <div class="git-source-banner">
18257 <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>
18258 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18259 <a href="/git-browser">← Back to Git Browser</a>
18260 </div>
18261 {% endif %}
18262 <div class="path-scope-grid">
18263 {% if !git_repo.is_empty() %}
18264 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18265 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18266 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18267 {% else %}
18268 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18269 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18270 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18271 {% endif %}
18272 <div class="path-scope-sep"></div>
18273 <div class="scope-legend-row">
18274 <span class="scope-legend-label">Scope legend:</span>
18275 <span class="scope-legend-badges">
18276 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18277 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18278 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18279 </span>
18280 </div>
18281 </div>
18282 {% if git_repo.is_empty() %}
18283 {% if server_mode %}
18284 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18285 ℹ️ Files are compressed and streamed — no fixed size limit.
18286 </div>
18287 {% endif %}
18288 <div class="path-info-row">
18289 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18290 <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>
18291 <span id="project-size-text">Project size: —</span>
18292 </button>
18293 </div>
18294 {% else %}
18295 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18296 {% endif %}
18297 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18298 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18299 </div>
18300
18301 <div class="scope-preview-divider" aria-hidden="true"></div>
18302
18303 <div id="preview-panel">
18304 <div class="preview-error">Loading preview...</div>
18305 </div>
18306 </div>
18307
18308 <div class="section" style="margin-top:14px;">
18309 <div class="preset-inline-row git-inline-row">
18310 <div class="toggle-card" style="margin:0;">
18311 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18312 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18313 <label class="checkbox">
18314 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18315 <div>
18316 <span>Detect and separate git submodules</span>
18317 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18318 </div>
18319 </label>
18320 </div>
18321 <div class="explainer-card prominent" style="margin:0;">
18322 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18323 <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>
18324 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18325 path = libs/core
18326 url = https://github.com/org/core.git
18327
18328[submodule "libs/ui"]
18329 path = libs/ui
18330 url = https://github.com/org/ui.git</div>
18331 </div>
18332 </div>
18333 </div>
18334
18335 <div class="section">
18336 <div class="field-grid">
18337 <div class="field">
18338 <div class="glob-label-row">
18339 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18340 <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>
18341 </div>
18342 <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>
18343 <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>
18344 </div>
18345 <div class="field">
18346 <div class="glob-label-row">
18347 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18348 </div>
18349 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
18350 <div id="quick-exclude-chips" class="quick-excl-row">
18351 <span class="quick-excl-label">Quick add:</span>
18352 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18353 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18354 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18355 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18356 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18357 <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>
18358 </div>
18359 <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>
18360 </div>
18361 </div>
18362 <div class="glob-guidance-grid">
18363 <div class="glob-guidance-card">
18364 <strong>How to read them</strong>
18365 <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>
18366 </div>
18367 <div class="glob-guidance-card">
18368 <strong>Common include examples</strong>
18369 <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>
18370 </div>
18371 <div class="glob-guidance-card">
18372 <strong>Common exclude examples</strong>
18373 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18374 </div>
18375 </div>
18376 </div>
18377
18378 <div class="section" style="margin-top:14px;">
18379 <div class="preset-inline-row git-inline-row">
18380 <div class="toggle-card" style="margin:0;">
18381 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18382 <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>
18383 <div class="field" style="margin:0;">
18384 <div class="input-group compact">
18385 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18386 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18387 </div>
18388 <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>
18389 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18390 </div>
18391 </div>
18392 <div class="explainer-card prominent" style="margin:0;">
18393 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18394 <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>
18395 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18396lcov --capture --directory . --output-file coverage/lcov.info
18397
18398# C / C++ — llvm-cov (LCOV)
18399llvm-profdata merge -sparse default.profraw -o default.profdata
18400llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18401
18402# C# — coverlet (Cobertura XML)
18403dotnet test --collect:"XPlat Code Coverage"
18404
18405# Python — pytest-cov (Cobertura XML)
18406pytest --cov --cov-report=xml
18407
18408# Python — coverage.py native JSON
18409coverage run -m pytest && coverage json # writes coverage.json
18410
18411# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18412./gradlew jacocoTestReport</div>
18413 </div>
18414 </div>
18415 </div>
18416
18417 <div class="wizard-actions">
18418 <div class="left"></div>
18419 <div class="right">
18420 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18421 <span class="preview-gate-spinner" aria-hidden="true"></span>
18422 <span class="preview-gate-text">Scanning project scope…</span>
18423 <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">
18424 <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>
18425 </button>
18426 </div>
18427 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18428 </div>
18429 </div>
18430 </div>
18431
18432 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18433 <div class="default-path-modal">
18434 <h3 id="default-path-title">
18435 <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>
18436 Proceed with the default sample test?
18437 </h3>
18438 <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18439 <p>You haven't selected your own project yet.</p>
18440 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18441 <div class="default-path-actions">
18442 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18443 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18444 </div>
18445 </div>
18446 </div>
18447
18448 <div class="wizard-step" data-step="2">
18449 <div class="section">
18450 <div class="section-kicker">Step 2</div>
18451 <h2>Choose counting behavior</h2>
18452 <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>
18453<div class="subsection-bar">Primary line classification</div>
18454 <div class="preset-kv-row">
18455 <div class="toggle-card mixed-line-card" style="margin:0;">
18456 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18457 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18458 <select id="mixed_line_policy" name="mixed_line_policy">
18459 <option value="code_only">Code only</option>
18460 <option value="code_and_comment">Code and comment</option>
18461 <option value="comment_only">Comment only</option>
18462 <option value="separate_mixed_category">Separate mixed category</option>
18463 </select>
18464 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18465 </div>
18466 <div class="explainer-card prominent" style="margin:0;">
18467 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18468 <div class="explainer-body" id="mixed-policy-description"></div>
18469 <div class="code-sample" id="mixed-policy-example"></div>
18470 </div>
18471 </div>
18472 </div>
18473
18474 <div class="subsection-bar">Additional scan rules</div>
18475 <div class="scan-rules-grid">
18476 <div class="preset-inline-row">
18477 <div class="toggle-card" style="margin:0;">
18478 <div class="field-help-title">Generated files</div>
18479 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18480 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18481 </div>
18482 <div class="explainer-card prominent" style="margin:0;">
18483 <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>
18484 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18485# Files matching codegen patterns are excluded:
18486# *.generated.cs *.pb.go *.g.dart</div>
18487 </div>
18488 </div>
18489 <div class="preset-inline-row">
18490 <div class="toggle-card" style="margin:0;">
18491 <div class="field-help-title">Minified files</div>
18492 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18493 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18494 </div>
18495 <div class="explainer-card prominent" style="margin:0;">
18496 <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>
18497 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18498# Heuristic: very long lines + low whitespace ratio
18499# jquery.min.js bundle.min.css → skipped</div>
18500 </div>
18501 </div>
18502 <div class="preset-inline-row">
18503 <div class="toggle-card" style="margin:0;">
18504 <div class="field-help-title">Vendor directories</div>
18505 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18506 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18507 </div>
18508 <div class="explainer-card prominent" style="margin:0;">
18509 <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>
18510 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18511# Directories named vendor/ node_modules/ third_party/
18512# → entire subtree is excluded from totals</div>
18513 </div>
18514 </div>
18515 <div class="preset-inline-row">
18516 <div class="toggle-card" style="margin:0;">
18517 <div class="field-help-title">Lockfiles and manifests</div>
18518 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18519 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18520 </div>
18521 <div class="explainer-card prominent" style="margin:0;">
18522 <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>
18523 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
18524# Files like package-lock.json Cargo.lock yarn.lock
18525# → skipped unless this is enabled</div>
18526 </div>
18527 </div>
18528 <div class="preset-inline-row">
18529 <div class="toggle-card" style="margin:0;">
18530 <div class="field-help-title">Binary handling</div>
18531 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18532 <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>
18533 </div>
18534 <div class="explainer-card prominent" style="margin:0;">
18535 <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>
18536 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
18537# Detected via long lines + low whitespace heuristic
18538# .png .exe .so → skipped silently</div>
18539 </div>
18540 </div>
18541 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18542 <div class="toggle-card" style="margin:0;">
18543 <div class="field-help-title">Python docstrings</div>
18544 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18545 <label class="checkbox">
18546 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18547 <span>Count as comment-style lines</span>
18548 </label>
18549 </div>
18550 <div class="explainer-card prominent" style="margin:0;">
18551 <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>
18552 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18553 </div>
18554 </div>
18555 </div>
18556 <div class="subsection-bar">IEEE 1045-1992 counting</div>
18557 <div class="scan-rules-grid">
18558 <div class="preset-inline-row">
18559 <div class="toggle-card" style="margin:0;">
18560 <div class="field-help-title">Continuation lines</div>
18561 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18562 <select name="continuation_line_policy" id="continuation_line_policy">
18563 <option value="each_physical_line" selected>Each physical line (default)</option>
18564 <option value="collapse_to_logical">Collapse to logical line</option>
18565 </select>
18566 </div>
18567 <div class="explainer-card prominent" style="margin:0;">
18568 <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>
18569 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18570 ((a) > (b) ? (a) : (b))
18571# each_physical_line → 2 SLOC
18572# collapse_to_logical → 1 SLOC</div>
18573 </div>
18574 </div>
18575 <div class="preset-inline-row">
18576 <div class="toggle-card" style="margin:0;">
18577 <div class="field-help-title">Block-comment blanks</div>
18578 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18579 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18580 <option value="count_as_comment" selected>Count as comment (default)</option>
18581 <option value="count_as_blank">Count as blank</option>
18582 </select>
18583 </div>
18584 <div class="explainer-card prominent" style="margin:0;">
18585 <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>
18586 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18587 * Summary line
18588 * ← blank inside block comment
18589 * Detail line
18590 */
18591# count_as_comment → blank counts toward comments
18592# count_as_blank → blank counts toward blanks</div>
18593 </div>
18594 </div>
18595 <div class="preset-inline-row">
18596 <div class="toggle-card" style="margin:0;">
18597 <div class="field-help-title">Compiler directives</div>
18598 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18599 <select name="count_compiler_directives" id="count_compiler_directives">
18600 <option value="enabled" selected>Include in code SLOC (default)</option>
18601 <option value="disabled">Exclude from code SLOC</option>
18602 </select>
18603 </div>
18604 <div class="explainer-card prominent" style="margin:0;">
18605 <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>
18606 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
18607#define BUF 256 ← compiler directive
18608int main() { … } ← code
18609# enabled → 3 code SLOC
18610# disabled → 1 code SLOC + 2 directive lines</div>
18611 </div>
18612 </div>
18613 </div>
18614
18615 <div class="subsection-bar">Code Style Analysis</div>
18616 <div class="scan-rules-grid">
18617 <div class="preset-inline-row">
18618 <div class="toggle-card" style="margin:0;">
18619 <div class="field-help-title">Style analysis</div>
18620 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18621 <select name="style_analysis_enabled" id="style_analysis_enabled">
18622 <option value="enabled" selected>Enabled (default)</option>
18623 <option value="disabled">Disabled — skip style scoring</option>
18624 </select>
18625 </div>
18626 <div class="explainer-card prominent" style="margin:0;">
18627 <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>
18628 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
18629# style_analysis_enabled = false (skip, faster scan)
18630# Disabling removes the Code Style section from the report.</div>
18631 </div>
18632 </div>
18633 <div class="preset-inline-row">
18634 <div class="toggle-card" style="margin:0;">
18635 <div class="field-help-title">Column-width threshold</div>
18636 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18637 <select name="style_col_threshold" id="style_col_threshold">
18638 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18639 <option value="100">100 columns (Uber Go, Google Java)</option>
18640 <option value="120">120 columns (Uber Go max, Kotlin)</option>
18641 </select>
18642 </div>
18643 <div class="explainer-card prominent" style="margin:0;">
18644 <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>
18645 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
18646# style_col_threshold = 100 (Uber Go, Google Java)
18647# style_col_threshold = 120 (Uber Go max, Kotlin)
18648# Files where <= 5% of lines exceed the limit
18649# are counted as "N-col compliant" in the report.</div>
18650 </div>
18651 </div>
18652 <div class="preset-inline-row">
18653 <div class="toggle-card" style="margin:0;">
18654 <div class="field-help-title">Score alert threshold</div>
18655 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18656 <select name="style_score_threshold" id="style_score_threshold">
18657 <option value="0" selected>Off — no threshold (default)</option>
18658 <option value="40">40% — flag poorly styled files</option>
18659 <option value="50">50% — flag below-average files</option>
18660 <option value="60">60% — flag below-good files</option>
18661 <option value="70">70% — flag below-strong files</option>
18662 </select>
18663 </div>
18664 <div class="explainer-card prominent" style="margin:0;">
18665 <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>
18666 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
18667# style_score_threshold = 50 (flag files < 50%)
18668# Low-scoring files get a red left-border in the
18669# per-file style breakdown table.</div>
18670 </div>
18671 </div>
18672 </div>
18673
18674 <div class="always-tracked-tip">
18675 <div class="always-tracked-tip-icon">ℹ</div>
18676 <div class="always-tracked-tip-body">
18677 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
18678 <h4>Comment and blank-line basics & Lines on the boundary</h4>
18679 <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>
18680 </div>
18681 </div>
18682
18683 <div class="subsection-bar">Advanced Metrics</div>
18684 <div class="scan-rules-grid">
18685 <div class="preset-inline-row">
18686 <div class="toggle-card" style="margin:0;">
18687 <div class="field-help-title">COCOMO mode</div>
18688 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18689 <select name="cocomo_mode" id="cocomo_mode">
18690 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18691 <option value="semi_detached">Semi-detached — mixed constraints</option>
18692 <option value="embedded">Embedded — tight hardware/OS constraints</option>
18693 </select>
18694 </div>
18695 <div class="explainer-card prominent" style="margin:0;">
18696 <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>
18697 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
18698# Semi-detached: Effort = 3.0 × KSLOC^1.12
18699# Embedded: Effort = 3.6 × KSLOC^1.20
18700# All modes: Schedule = 2.5 × Effort^d</div>
18701 </div>
18702 </div>
18703 <div class="preset-inline-row">
18704 <div class="toggle-card" style="margin:0;">
18705 <div class="field-help-title">Complexity alert</div>
18706 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18707 <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;" />
18708 </div>
18709 <div class="explainer-card prominent" style="margin:0;">
18710 <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>
18711 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18712# 50 = flag any file with > 50 branch points
18713# 100 = flag any file with > 100 branch points
18714# Files above the threshold are highlighted
18715# in the result page metric strip.</div>
18716 </div>
18717 </div>
18718 <div class="preset-inline-row">
18719 <div class="toggle-card" style="margin:0;">
18720 <div class="field-help-title">Git hotspots</div>
18721 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18722 <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;" />
18723 </div>
18724 <div class="explainer-card prominent" style="margin:0;">
18725 <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>
18726 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
18727# 30 = last month of activity
18728# 365 = last year
18729# 0 = disable the hotspots table
18730# Adds Commits + Last-changed columns to CSV.</div>
18731 </div>
18732 </div>
18733 <div class="preset-inline-row">
18734 <div class="toggle-card" style="margin:0;">
18735 <div class="field-help-title">Duplicate handling</div>
18736 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18737 <select name="exclude_duplicates" id="exclude_duplicates">
18738 <option value="disabled" selected>Detect and report only (default)</option>
18739 <option value="enabled">Detect and exclude from SLOC totals</option>
18740 </select>
18741 </div>
18742 <div class="explainer-card prominent" style="margin:0;">
18743 <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>
18744 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18745# detect only → all 3 counted in SLOC
18746# exclude dupes → 1 counted, 2 excluded
18747# Duplicate groups chip always shows the count.</div>
18748 </div>
18749 </div>
18750 <div class="always-tracked-tip" style="margin:8px 0 0;">
18751 <div class="always-tracked-tip-icon">ℹ</div>
18752 <div class="always-tracked-tip-body">
18753 <div class="field-help-title">Always computed — every scan produces these automatically</div>
18754 <div class="always-tracked-metrics-row">
18755 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18756 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
18757 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
18758 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
18759 </div>
18760 <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>
18761 </div>
18762 </div>
18763 </div>
18764
18765 <div class="wizard-actions">
18766 <div class="left">
18767 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18768 </div>
18769 <div class="right">
18770 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18771 </div>
18772 </div>
18773 </div>
18774
18775 <div class="wizard-step" data-step="3">
18776 <div class="section">
18777 <div class="section-kicker">Step 3</div>
18778 <h2>Output and report identity</h2>
18779 <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>
18780 <div class="preset-kv-row">
18781 <div class="toggle-card" style="margin:0;">
18782 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18783 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18784 <select id="scan_preset">
18785 <option value="balanced">Balanced local scan</option>
18786 <option value="code_focused">Code focused</option>
18787 <option value="comment_audit">Comment audit</option>
18788 <option value="deep_review">Deep review</option>
18789 </select>
18790 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18791 </div>
18792 <div class="explainer-card">
18793 <div class="field-help-title">Selected scan preset</div>
18794 <div class="explainer-body" id="scan-preset-description"></div>
18795 <div class="preset-summary-row" id="scan-preset-summary"></div>
18796 <div class="code-sample" id="scan-preset-example"></div>
18797 <div class="preset-note" id="scan-preset-note"></div>
18798 </div>
18799 </div>
18800 <hr class="step3-separator" />
18801 <div class="preset-kv-row">
18802 <div class="toggle-card" style="margin:0;">
18803 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18804 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18805 <select id="artifact_preset">
18806 <option value="review">Review bundle</option>
18807 <option value="full">Full bundle</option>
18808 <option value="html_only">HTML only</option>
18809 <option value="machine">Machine bundle</option>
18810 </select>
18811 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18812 </div>
18813 <div class="explainer-card">
18814 <div class="field-help-title">Selected artifact preset</div>
18815 <div class="explainer-body" id="artifact-preset-description"></div>
18816 <div class="preset-summary-row" id="artifact-preset-summary"></div>
18817 <div class="code-sample" id="artifact-preset-example"></div>
18818 </div>
18819 </div>
18820 </div>
18821
18822 <div class="section section-spacer-top">
18823 <div class="output-field-row">
18824 <div class="field">
18825 <label for="output_dir">Output directory</label>
18826 {% if server_mode %}
18827 <div class="input-group compact">
18828 <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);" />
18829 </div>
18830 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18831 {% else %}
18832 <div class="input-group compact">
18833 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18834 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18835 <button type="button" class="mini-button" id="use-default-output">Use default</button>
18836 </div>
18837 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18838 {% endif %}
18839 </div>
18840 <div class="output-field-aside">
18841 <strong>Where reports land</strong>
18842 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.
18843 </div>
18844 </div>
18845 </div>
18846
18847 <div class="section section-spacer-top">
18848 <div class="output-field-row">
18849 <div class="field">
18850 <label for="report_title">Report title</label>
18851 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18852 <div class="hint">Appears in HTML and PDF output headers.</div>
18853 </div>
18854 <div class="output-field-aside">
18855 <strong>Shown in exported artifacts</strong>
18856 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.
18857 </div>
18858 </div>
18859 </div>
18860
18861 <div class="section section-spacer-top">
18862 <div class="output-field-row">
18863 <div class="field">
18864 <label for="report_header_footer">Report header / footer</label>
18865 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18866 <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>
18867 </div>
18868 <div class="output-field-aside">
18869 <strong>Page-level identification</strong>
18870 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.
18871 </div>
18872 </div>
18873 </div>
18874
18875 <div class="wizard-actions">
18876 <div class="left">
18877 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18878 </div>
18879 <div class="right">
18880 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18881 </div>
18882 </div>
18883 </div>
18884
18885 <div class="wizard-step" data-step="4">
18886 <div class="section">
18887 <div class="section-kicker">Step 4</div>
18888 <h2>Review selections and run</h2>
18889 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18890 <div class="review-grid">
18891 <div class="review-card highlight">
18892 <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>
18893 <ul id="review-scan-summary"></ul>
18894 </div>
18895 <div class="review-card highlight">
18896 <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>
18897 <ul id="review-count-summary"></ul>
18898 </div>
18899 <div class="review-card">
18900 <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>
18901 <ul id="review-artifact-summary"></ul>
18902 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18903 </div>
18904 <div class="review-card">
18905 <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>
18906 <ul id="review-preview-summary"></ul>
18907 </div>
18908 </div>
18909 </div>
18910
18911 <div class="wizard-actions">
18912 <div class="left">
18913 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18914 </div>
18915 <div class="right">
18916 <button type="submit" id="submit-button" class="primary">Run analysis</button>
18917 </div>
18918 </div>
18919 </div>
18920 {% if server_mode %}
18921 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18922 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18923 {% endif %}
18924 </form>
18925 </div>
18926 </section>
18927 </div>
18928 </div>
18929
18930 <script nonce="{{ csp_nonce }}">
18931 (function () {
18932 function startScanPhase() {
18933 var phaseEl = document.getElementById("scan-phase");
18934 if (!phaseEl) return;
18935 var phases = [
18936 "Discovering files...",
18937 "Decoding file encodings...",
18938 "Detecting languages...",
18939 "Analyzing source lines...",
18940 "Applying counting policies...",
18941 "Aggregating results...",
18942 "Rendering report..."
18943 ];
18944 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18945 var i = 0;
18946 function next() {
18947 phaseEl.style.opacity = "0";
18948 setTimeout(function () {
18949 phaseEl.textContent = phases[i];
18950 phaseEl.style.opacity = "0.85";
18951 var delay = durations[i] || 1800;
18952 i++;
18953 if (i < phases.length) { setTimeout(next, delay); }
18954 }, 200);
18955 }
18956 next();
18957 }
18958
18959 var form = document.getElementById("analyze-form");
18960 var loading = document.getElementById("loading");
18961 var submitButton = document.getElementById("submit-button");
18962 var pathInput = document.getElementById("path");
18963 var GIT_MODE = !!(pathInput && pathInput.readOnly);
18964 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18965 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18966 var outputDirInput = document.getElementById("output_dir");
18967 var reportTitleInput = document.getElementById("report_title");
18968 var previewPanel = document.getElementById("preview-panel");
18969 var refreshButton = document.getElementById("refresh-preview");
18970 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18971 var useSamplePath = document.getElementById("use-sample-path");
18972 var useDefaultOutput = document.getElementById("use-default-output");
18973 var browsePath = document.getElementById("browse-path");
18974 var browseOutputDir = document.getElementById("browse-output-dir");
18975 var browseCoverage = document.getElementById("browse-coverage");
18976 var coverageInput = document.getElementById("coverage_file");
18977 var covScanStatus = document.getElementById("cov-scan-status");
18978 var coverageSuggestTimer = null;
18979 var covAutoFilled = false;
18980 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18981
18982 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18983 (function() {
18984 var ids = ["path", "output_dir"];
18985 ids.forEach(function(id) {
18986 var el = document.getElementById(id);
18987 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18988 });
18989 }());
18990 function fmtBytes(b) {
18991 b = Number(b) || 0;
18992 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18993 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18994 if (b >= 1024) return Math.round(b / 1024) + ' KB';
18995 return b + ' B';
18996 }
18997 var themeToggle = document.getElementById("theme-toggle");
18998
18999 function showBannerToast(msg, isError, opts) {
19000 opts = opts || {};
19001 var t = document.createElement('div');
19002 t.className = isError ? 'toast-error' : 'toast-success';
19003 var topPos = opts.top ? '80px' : null;
19004 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19005 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19006 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19007 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19008 if (opts.icon) {
19009 var inner = document.createElement('span');
19010 inner.innerHTML = opts.icon + ' ';
19011 t.appendChild(inner);
19012 }
19013 t.appendChild(document.createTextNode(msg));
19014 document.body.appendChild(t);
19015 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19016 }
19017 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19018 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19019 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19020 var scanPreset = document.getElementById("scan_preset");
19021 var artifactPreset = document.getElementById("artifact_preset");
19022 var includeGlobsInput = document.getElementById("include_globs");
19023 var excludeGlobsInput = document.getElementById("exclude_globs");
19024
19025 // Include globs scope badge — updates reactively as the user types.
19026 (function() {
19027 var badge = document.getElementById("include-scope-badge");
19028 if (!badge || !includeGlobsInput) return;
19029 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> ';
19030 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> ';
19031 function update() {
19032 var val = includeGlobsInput.value.trim();
19033 if (!val) {
19034 badge.className = "include-scope-badge scope-all";
19035 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19036 } else {
19037 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19038 badge.className = "include-scope-badge scope-narrow";
19039 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19040 }
19041 }
19042 includeGlobsInput.addEventListener("input", update);
19043 update();
19044 }());
19045
19046 // Quick-exclude chips — append pattern to exclude_globs textarea.
19047 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19048 chip.addEventListener("click", function() {
19049 var pattern = chip.getAttribute("data-pattern") || "";
19050 if (!pattern || !excludeGlobsInput) return;
19051 var current = excludeGlobsInput.value.trim();
19052 // For the "skip all" chip, replace any existing dep patterns cleanly.
19053 var patterns = pattern.split("\n");
19054 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19055 var added = false;
19056 patterns.forEach(function(p) {
19057 p = p.trim();
19058 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19059 });
19060 if (added) {
19061 excludeGlobsInput.value = lines.join("\n");
19062 excludeGlobsInput.dispatchEvent(new Event("input"));
19063 }
19064 chip.classList.add("active");
19065 });
19066 });
19067
19068 var liveReportTitle = document.getElementById("live-report-title");
19069 var navProjectPill = document.getElementById("nav-project-pill");
19070 var navProjectTitle = document.getElementById("nav-project-title");
19071 var reportTitlePreview = null;
19072 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19073 var wizardProgressValue = document.getElementById("wizard-progress-value");
19074 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19075 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19076 var reportTitleTouched = false;
19077 var currentStep = 1;
19078 var previewTimer = null;
19079 var _previewGen = 0;
19080 // True while the scope preview (local) / project upload (server mode) is in
19081 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19082 // user can't advance past a project whose scope/upload isn't ready yet.
19083 var previewLoading = false;
19084 // Set when the current preview reports multiple independent git repos under
19085 // the selected root. Advancing past step 1 is blocked until the user ticks
19086 // the acknowledgement checkbox (or re-selects a single repository).
19087 var multiRepoBlocked = false;
19088 function step1ForwardBlocked() {
19089 return previewLoading || multiRepoBlocked;
19090 }
19091 function refreshStep1Gate() {
19092 var nextBtn = document.getElementById("step1-next");
19093 if (nextBtn) {
19094 var blocked = step1ForwardBlocked();
19095 nextBtn.classList.toggle("is-blocked", blocked);
19096 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19097 }
19098 }
19099 function setPreviewLoading(loading) {
19100 previewLoading = !!loading;
19101 var gate = document.getElementById("preview-gate-status");
19102 refreshStep1Gate();
19103 if (gate) {
19104 var txt = gate.querySelector(".preview-gate-text");
19105 if (txt) txt.textContent = SERVER_MODE
19106 ? "Uploading & scanning project…"
19107 : "Scanning project scope…";
19108 gate.style.display = previewLoading ? "flex" : "none";
19109 }
19110 }
19111 // Info button on the gate: scroll up to the live scope preview so the user
19112 // can see exactly what is being scanned (elapsed time + rotating status).
19113 var previewGateInfo = document.getElementById("preview-gate-info");
19114 if (previewGateInfo) {
19115 previewGateInfo.addEventListener("click", function () {
19116 var target = document.getElementById("preview-panel");
19117 if (!target) return;
19118 target.scrollIntoView({ behavior: "smooth", block: "center" });
19119 target.classList.add("preview-panel-flash");
19120 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19121 });
19122 }
19123 var quickScanBtn = document.getElementById("quick-scan-btn");
19124
19125 function dismissAnalysisModal() {
19126 if (loading) loading.classList.remove("active");
19127 document.body.classList.remove("modal-open");
19128 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19129 var el = document.getElementById(id);
19130 if (el) el.classList.add("hidden");
19131 });
19132 var cancelBtn = document.getElementById("lc-cancel-btn");
19133 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19134 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19135 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19136 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19137 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");}
19138 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19139 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19140 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19141 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19142 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19143 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19144 }
19145
19146 var lcDismissBtn = document.getElementById("lc-dismiss");
19147 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19148
19149 // When the browser restores this page from bfcache (Back button after navigating to results),
19150 // the loading overlay would still be showing its active state. Dismiss it immediately.
19151 window.addEventListener("pageshow", function(e) {
19152 if (e.persisted) { dismissAnalysisModal(); }
19153 });
19154
19155 function startAsyncAnalysis(formData) {
19156 var gitRepo = (formData.get("git_repo") || "").toString();
19157 var gitRef = (formData.get("git_ref") || "").toString();
19158 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19159 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19160
19161 var pathEl = document.getElementById("lc-path-text");
19162 if (pathEl) pathEl.textContent = displayPath;
19163
19164 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19165 var el = document.getElementById(id);
19166 if (el) el.classList.add("hidden");
19167 });
19168 var cancelBtn = document.getElementById("lc-cancel-btn");
19169 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19170 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19171 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19172 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19173 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19174 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19175 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19176 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");}
19177 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19178
19179 if (loading) loading.classList.add("active");
19180 document.body.classList.add("modal-open");
19181
19182 var startTime = Date.now();
19183 var elapsedTimer = setInterval(function() {
19184 var s = Math.floor((Date.now() - startTime) / 1000);
19185 var el = document.getElementById("lc-elapsed");
19186 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19187 }, 1000);
19188
19189 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19190
19191 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();}
19192
19193 var PHASE_DESC = {
19194 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19195 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19196 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19197 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19198 'Done': 'Analysis complete \u2014 loading your results\u2026',
19199 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19200 };
19201 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19202 function lcSetPhase(txt) {
19203 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19204 var desc = document.getElementById("lc-stage-desc");
19205 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19206 var step = PHASE_STEP[txt] || 1;
19207 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");}
19208 }
19209
19210 function lcShowCancelled() {
19211 clearInterval(elapsedTimer);
19212 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19213 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19214 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19215 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19216 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19217 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19218 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19219 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19220 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19221 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19222 }
19223
19224 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19225 if (lcCancelBtn) {
19226 lcCancelBtn.onclick = function() {
19227 if (!activeWaitId) { dismissAnalysisModal(); return; }
19228 lcCancelBtn.disabled = true;
19229 lcCancelBtn.textContent = "Cancelling\u2026";
19230 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19231 .then(function() { lcShowCancelled(); })
19232 .catch(function() { lcShowCancelled(); });
19233 };
19234 }
19235
19236 function lcShowError(msg) {
19237 clearInterval(elapsedTimer);
19238 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19239 lcSetPhase("Failed");
19240 var msgEl = document.getElementById("lc-err-msg");
19241 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19242 var errEl = document.getElementById("lc-err");
19243 var actEl = document.getElementById("lc-actions");
19244 if (errEl) errEl.classList.remove("hidden");
19245 if (actEl) actEl.classList.remove("hidden");
19246 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19247 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19248 }
19249
19250 function lcPoll(waitId) {
19251 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19252 .then(function(r) {
19253 if (!r.ok) throw new Error("HTTP " + r.status);
19254 return r.json();
19255 })
19256 .then(function(data) {
19257 pollRetries = 0;
19258 if (data.state === "complete") {
19259 clearInterval(elapsedTimer);
19260 lcSetPhase("Done");
19261 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19262 } else if (data.state === "failed") {
19263 lcShowError(data.message);
19264 } else if (data.state === "cancelled") {
19265 lcShowCancelled();
19266 } else {
19267 var s = Math.floor((Date.now() - startTime) / 1000);
19268 if (s > 90 && !warnShown) {
19269 warnShown = true;
19270 var w = document.getElementById("lc-warn");
19271 if (w) w.classList.remove("hidden");
19272 }
19273 lcSetPhase(data.phase || "Running");
19274 var fd = data.files_done || 0, ft = data.files_total || 0;
19275 if (ft > 0) {
19276 var card = document.getElementById("lc-files-card");
19277 if (card) card.classList.remove("hidden");
19278 var el = document.getElementById("lc-files");
19279 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19280 var now = Date.now();
19281 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19282 if (fdelta > 0 && tdelta > 0.4) {
19283 var fps = Math.round(fdelta / tdelta);
19284 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19285 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19286 }
19287 lastFd = fd; lastFdTime = now;
19288 }
19289 setTimeout(function() { lcPoll(waitId); }, 1500);
19290 }
19291 })
19292 .catch(function() {
19293 pollRetries++;
19294 if (pollRetries >= 5) {
19295 lcShowError("Lost connection to server. Reload to check status.");
19296 } else {
19297 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19298 }
19299 });
19300 }
19301
19302 var params = new URLSearchParams(formData);
19303 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19304 .then(function(r) {
19305 var waitId = r.headers.get("x-wait-id");
19306 if (!waitId) { window.location.href = "/scan"; return; }
19307 activeWaitId = waitId;
19308 setTimeout(function() { lcPoll(waitId); }, 1500);
19309 })
19310 .catch(function(err) {
19311 lcShowError("Could not reach server: " + (err.message || err));
19312 });
19313 }
19314
19315 if (quickScanBtn) {
19316 quickScanBtn.addEventListener("click", function () {
19317 var pathVal = pathInput ? pathInput.value.trim() : "";
19318 if (!pathVal) {
19319 alert("Please enter or browse to a project path first.");
19320 return;
19321 }
19322 quickScanBtn.disabled = true;
19323 quickScanBtn.textContent = "Scanning...";
19324 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19325 startAsyncAnalysis(new FormData(form));
19326 });
19327 }
19328
19329 var mixedPolicyInfo = {
19330 code_only: {
19331 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.",
19332 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'
19333 },
19334 code_and_comment: {
19335 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.",
19336 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'
19337 },
19338 comment_only: {
19339 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.",
19340 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'
19341 },
19342 separate_mixed_category: {
19343 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.",
19344 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'
19345 }
19346 };
19347
19348 var scanPresetInfo = {
19349 balanced: {
19350 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.",
19351 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19352 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19353 note: "Best when you want a stable local overview before making deeper adjustments.",
19354 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19355 },
19356 code_focused: {
19357 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19358 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19359 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19360 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19361 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19362 },
19363 comment_audit: {
19364 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19365 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19366 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19367 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19368 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19369 },
19370 deep_review: {
19371 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19372 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19373 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19374 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19375 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19376 }
19377 };
19378
19379 var artifactPresetInfo = {
19380 review: {
19381 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19382 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19383 example: "Ideal for a quick local review before sharing results."
19384 },
19385 full: {
19386 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19387 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19388 example: "Use when producing a deliverable or storing a snapshot for future comparison."
19389 },
19390 html_only: {
19391 description: "Standalone HTML report only. No PDF generation, no data files.",
19392 chips: ["HTML only"],
19393 example: "Fastest option when you only need to open the report in a browser."
19394 },
19395 machine: {
19396 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19397 chips: ["JSON", "CSV", "no HTML", "no PDF"],
19398 example: "Use in CI to capture metrics without generating visual reports."
19399 }
19400 };
19401
19402 function applyArtifactPreset() {
19403 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19404 if (!info) return;
19405 var descEl = document.getElementById("artifact-preset-description");
19406 var exampleEl = document.getElementById("artifact-preset-example");
19407 if (descEl) descEl.textContent = info.description;
19408 if (exampleEl) exampleEl.textContent = info.example;
19409 renderPresetChips("artifact-preset-summary", info.chips);
19410 }
19411
19412 function applyTheme(theme) {
19413 if (theme === "dark") document.body.classList.add("dark-theme");
19414 else document.body.classList.remove("dark-theme");
19415 }
19416
19417 function loadSavedTheme() {
19418 var saved = null;
19419 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19420 applyTheme(saved === "dark" ? "dark" : "light");
19421 }
19422
19423 function updateScrollProgress() {
19424 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19425 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19426 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19427 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19428 var step = Math.min(Math.max(currentStep, 1), 4);
19429 var base = stepBase[step];
19430 var end = stepEnd[step];
19431
19432 var scrollFrac = 0;
19433 var activePanel = document.querySelector(".wizard-step.active");
19434 if (activePanel) {
19435 var scrollTop = window.scrollY || window.pageYOffset || 0;
19436 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19437 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19438 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19439 var scrolled = scrollTop + viewH - panelTop;
19440 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19441 }
19442
19443 var percent = Math.round(base + (end - base) * scrollFrac);
19444 percent = Math.min(end, Math.max(base, percent));
19445 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19446 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19447 }
19448
19449 function updateWizardProgress() {
19450 updateScrollProgress();
19451 }
19452
19453 var stepDescriptions = [
19454 "Choose a project folder, apply scope filters, and preview which files will be counted.",
19455 "Configure how mixed code-plus-comment lines and docstrings are classified.",
19456 "Pick your output formats, scan preset, and where reports are saved.",
19457 "Review all settings and launch the analysis."
19458 ];
19459
19460 function updateStepNav(step) {
19461 var infoLabel = document.getElementById("step-nav-info-label");
19462 var infoDesc = document.getElementById("step-nav-info-desc");
19463 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19464 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
19465 }
19466
19467 function updateSidebarSummary() {
19468 var sumPath = document.getElementById("sum-path");
19469 var sumPreset = document.getElementById("sum-preset");
19470 var sumOutput = document.getElementById("sum-output");
19471 var sidebarSummary = document.getElementById("sidebar-summary");
19472 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19473 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
19474 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19475 if (sumPath) sumPath.textContent = pathVal || "\u2014";
19476 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19477 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19478 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19479 }
19480
19481 function setStep(step, pushHistory) {
19482 currentStep = step;
19483 stepPanels.forEach(function (panel) {
19484 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19485 });
19486 stepButtons.forEach(function (button) {
19487 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19488 });
19489 var layoutEl = document.querySelector(".layout");
19490 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19491 updateWizardProgress();
19492 updateStepNav(step);
19493 stepButtons.forEach(function(btn) {
19494 var t = Number(btn.getAttribute("data-step-target"));
19495 btn.classList.toggle("done", t < step);
19496 });
19497 updateSidebarSummary();
19498
19499 if (pushHistory !== false) {
19500 try {
19501 history.pushState({ wizardStep: step }, "", "#step" + step);
19502 } catch (e) {}
19503 }
19504
19505 window.scrollTo({ top: 0, behavior: "instant" });
19506 }
19507
19508 window.addEventListener("popstate", function (e) {
19509 if (e.state && e.state.wizardStep) {
19510 setStep(e.state.wizardStep, false);
19511 } else {
19512 var hashMatch = location.hash.match(/^#step([1-4])$/);
19513 if (hashMatch) setStep(Number(hashMatch[1]), false);
19514 }
19515 });
19516
19517 function inferTitleFromPath(value) {
19518 if (!value) return "project";
19519 var cleaned = value.replace(/[\/\\]+$/, "");
19520 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19521 return parts.length ? parts[parts.length - 1] : value;
19522 }
19523
19524 function updateReportTitleFromPath() {
19525 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19526 if (!reportTitleTouched) {
19527 reportTitleInput.value = inferred;
19528 }
19529 var title = reportTitleInput.value || inferred;
19530 if (liveReportTitle) liveReportTitle.textContent = title;
19531 if (reportTitlePreview) reportTitlePreview.textContent = title;
19532 document.title = "OxideSLOC | " + title;
19533
19534 var projectPath = (pathInput.value || "").trim();
19535 if (navProjectPill && navProjectTitle) {
19536 if (projectPath.length > 0) {
19537 navProjectTitle.textContent = inferred;
19538 navProjectPill.classList.add("visible");
19539 } else {
19540 navProjectTitle.textContent = "";
19541 navProjectPill.classList.remove("visible");
19542 }
19543 }
19544 }
19545
19546 function updateMixedPolicyUI() {
19547 var key = mixedLinePolicy.value || "code_only";
19548 var info = mixedPolicyInfo[key];
19549 document.getElementById("mixed-policy-description").textContent = info.description;
19550 document.getElementById("mixed-policy-example").textContent = info.example;
19551 }
19552
19553 function updatePythonDocstringUI() {
19554 var checked = !!pythonDocstrings.checked;
19555 document.getElementById("python-docstring-example").textContent = checked
19556 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
19557 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
19558 document.getElementById("python-docstring-live-help").textContent = checked
19559 ? "Enabled: docstrings contribute to comment-style totals."
19560 : "Disabled: docstrings are not counted as comment content.";
19561 }
19562
19563 function renderPresetChips(targetId, chips) {
19564 var target = document.getElementById(targetId);
19565 if (!target) return;
19566 target.innerHTML = (chips || []).map(function (chip) {
19567 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19568 }).join('');
19569 }
19570
19571 function updatePresetDescriptions() {
19572 var scanInfo = scanPresetInfo[scanPreset.value];
19573 if (!scanInfo) return;
19574 document.getElementById("scan-preset-description").textContent = scanInfo.description;
19575 document.getElementById("scan-preset-example").textContent = scanInfo.example;
19576 document.getElementById("scan-preset-note").textContent = scanInfo.note;
19577 renderPresetChips("scan-preset-summary", scanInfo.chips);
19578 }
19579
19580 function applyScanPreset() {
19581 var info = scanPresetInfo[scanPreset.value];
19582 if (!info || !info.apply) return;
19583 mixedLinePolicy.value = info.apply.mixed;
19584 pythonDocstrings.checked = !!info.apply.docstrings;
19585 document.getElementById("generated_file_detection").value = info.apply.generated;
19586 document.getElementById("minified_file_detection").value = info.apply.minified;
19587 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19588 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19589 document.getElementById("binary_file_behavior").value = info.apply.binary;
19590 updateMixedPolicyUI();
19591 updatePythonDocstringUI();
19592 }
19593
19594 function updateReview() {
19595 var scanSummary = document.getElementById("review-scan-summary");
19596 var countSummary = document.getElementById("review-count-summary");
19597 var artifactSummary = document.getElementById("review-artifact-summary");
19598 var outputSummary = document.getElementById("review-output-summary");
19599 var previewSummary = document.getElementById("review-preview-summary");
19600 var readinessSummary = document.getElementById("review-readiness-summary");
19601 var includeText = document.getElementById("include_globs").value.trim();
19602 var excludeText = document.getElementById("exclude_globs").value.trim();
19603 var sidePathPreview = document.getElementById("side-path-preview");
19604 var sideOutputPreview = document.getElementById("side-output-preview");
19605 var sideTitlePreview = document.getElementById("side-title-preview");
19606
19607 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19608 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19609 if (sideTitlePreview) {
19610 var rt = document.getElementById("report_title");
19611 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19612 }
19613
19614 scanSummary.innerHTML = ""
19615 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19616 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19617 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19618
19619 countSummary.innerHTML = ""
19620 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19621 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19622 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19623 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19624 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19625 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19626 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19627 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19628
19629 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19630
19631 outputSummary.innerHTML = ""
19632 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19633 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19634
19635 if (previewSummary) {
19636 if (GIT_MODE) {
19637 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>';
19638 } else {
19639 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19640 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19641 var statMap = {};
19642 statButtons.forEach(function (button) {
19643 var valueNode = button.querySelector('.scope-stat-value');
19644 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19645 });
19646 previewSummary.innerHTML = ''
19647 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19648 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19649 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19650 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19651 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19652 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19653
19654 if (readinessSummary) {
19655 readinessSummary.innerHTML = ''
19656 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19657 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19658 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19659 }
19660 } // end else (non-GIT_MODE)
19661 }
19662 }
19663
19664 function escapeHtml(value) {
19665 return String(value)
19666 .replace(/&/g, "&")
19667 .replace(/</g, "<")
19668 .replace(/>/g, ">")
19669 .replace(/"/g, """)
19670 .replace(/'/g, "'");
19671 }
19672
19673 function isPythonVisible() {
19674 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19675 }
19676
19677 function syncPythonVisibility() {
19678 var html = previewPanel.textContent || "";
19679 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19680 pythonWraps.forEach(function (node) {
19681 node.classList.toggle("hidden", !hasPython);
19682 });
19683 }
19684
19685 function attachPreviewInteractions() {
19686 // Multiple-repository caution banner: gate step 1 until acknowledged, and
19687 // let each listed repo be picked as the scan root with one click.
19688 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
19689 if (multiRepoBanner) {
19690 multiRepoBlocked = true;
19691 refreshStep1Gate();
19692 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
19693 if (ackBox) {
19694 ackBox.addEventListener("change", function () {
19695 multiRepoBlocked = !ackBox.checked;
19696 refreshStep1Gate();
19697 });
19698 }
19699 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
19700 repoButtons.forEach(function (btn) {
19701 btn.addEventListener("click", function () {
19702 var repoPath = btn.getAttribute("data-repo-path") || "";
19703 if (!repoPath || !pathInput) return;
19704 pathInput.value = repoPath;
19705 scrollInputToEnd(pathInput);
19706 updateReportTitleFromPath();
19707 autoSetOutputDir(repoPath);
19708 fetchProjectHistory(repoPath);
19709 loadPreview();
19710 updateReview();
19711 });
19712 });
19713 }
19714 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19715 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19716 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19717 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19718 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19719 var searchInput = previewPanel.querySelector("#explorer-search");
19720 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19721 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19722 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19723 var activeFilter = "all";
19724 var activeLanguage = "";
19725 var searchTerm = "";
19726 var currentSortKey = null;
19727 var currentSortOrder = "asc";
19728 var childRows = {};
19729
19730 rows.forEach(function (row) {
19731 var parentId = row.getAttribute("data-parent-id") || "";
19732 var rowId = row.getAttribute("data-row-id") || "";
19733 if (!childRows[parentId]) childRows[parentId] = [];
19734 childRows[parentId].push(rowId);
19735 });
19736
19737 function rowById(id) {
19738 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19739 }
19740
19741 function hasCollapsedAncestor(row) {
19742 var parentId = row.getAttribute("data-parent-id");
19743 while (parentId) {
19744 var parent = rowById(parentId);
19745 if (!parent) break;
19746 if (parent.getAttribute("data-expanded") === "false") return true;
19747 parentId = parent.getAttribute("data-parent-id");
19748 }
19749 return false;
19750 }
19751
19752 function updateToggleGlyph(row) {
19753 var toggle = row.querySelector(".tree-toggle");
19754 if (!toggle) return;
19755 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19756 }
19757
19758 function rowSortValue(row, key) {
19759 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19760 }
19761
19762 function updateSortButtons() {
19763 sortButtons.forEach(function (button) {
19764 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19765 var indicator = button.querySelector(".tree-sort-indicator");
19766 button.classList.toggle("active", isActive);
19767 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19768 if (indicator) {
19769 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19770 }
19771 });
19772 }
19773
19774 function sortSiblingRows() {
19775 if (!treeContainer) {
19776 updateSortButtons();
19777 return;
19778 }
19779
19780 var rowMap = {};
19781 var childrenMap = {};
19782 rows.forEach(function (row) {
19783 var rowId = row.getAttribute("data-row-id");
19784 var parentId = row.getAttribute("data-parent-id") || "";
19785 rowMap[rowId] = row;
19786 if (!childrenMap[parentId]) childrenMap[parentId] = [];
19787 childrenMap[parentId].push(rowId);
19788 });
19789
19790 Object.keys(childrenMap).forEach(function (parentId) {
19791 if (!parentId) return;
19792 childrenMap[parentId].sort(function (a, b) {
19793 var rowA = rowMap[a];
19794 var rowB = rowMap[b];
19795 if (!currentSortKey) {
19796 return Number(a) - Number(b);
19797 }
19798 var valueA = rowSortValue(rowA, currentSortKey);
19799 var valueB = rowSortValue(rowB, currentSortKey);
19800 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19801 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19802 var fallbackA = rowSortValue(rowA, "name");
19803 var fallbackB = rowSortValue(rowB, "name");
19804 if (fallbackA < fallbackB) return -1;
19805 if (fallbackA > fallbackB) return 1;
19806 return Number(a) - Number(b);
19807 });
19808 });
19809
19810 var orderedIds = [];
19811 function pushChildren(parentId) {
19812 (childrenMap[parentId] || []).forEach(function (childId) {
19813 orderedIds.push(childId);
19814 pushChildren(childId);
19815 });
19816 }
19817
19818 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19819 orderedIds.push(topId);
19820 pushChildren(topId);
19821 });
19822
19823 orderedIds.forEach(function (id) {
19824 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19825 });
19826 updateSortButtons();
19827 }
19828
19829 function updateLanguageButtons() {
19830 languageButtons.forEach(function (button) {
19831 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19832 var isActive = languageValue === activeLanguage;
19833 button.classList.toggle("active", isActive);
19834 });
19835 }
19836
19837 function rowSelfMatches(row) {
19838 var kind = row.getAttribute("data-kind");
19839 var status = row.getAttribute("data-status");
19840 var language = (row.getAttribute("data-language") || "").toLowerCase();
19841 var name = row.getAttribute("data-name-lower") || "";
19842 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19843 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19844 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19845 var passesLanguage = !activeLanguage || language === activeLanguage;
19846 return passesFilter && passesSearch && passesLanguage;
19847 }
19848
19849 function hasMatchingDescendant(rowId) {
19850 return (childRows[rowId] || []).some(function (childId) {
19851 var childRow = rowById(childId);
19852 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19853 });
19854 }
19855
19856 function rowMatches(row) {
19857 if (rowSelfMatches(row)) return true;
19858 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19859 }
19860
19861 function resetViewState() {
19862 activeFilter = "all";
19863 activeLanguage = "";
19864 searchTerm = "";
19865 currentSortKey = null;
19866 currentSortOrder = "asc";
19867 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19868 if (searchInput) searchInput.value = "";
19869 if (filterSelect) filterSelect.value = "all";
19870 updateLanguageButtons();
19871 }
19872
19873 function applyVisibility() {
19874 rows.forEach(function (row) {
19875 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19876 row.classList.toggle("hidden-by-filter", !visible);
19877 row.style.display = visible ? "grid" : "none";
19878 });
19879 buttons.forEach(function (button) {
19880 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19881 });
19882 if (filterSelect) filterSelect.value = activeFilter;
19883 }
19884
19885 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19886 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19887 var originalStats = {};
19888 buttons.forEach(function (btn) {
19889 var f = btn.getAttribute('data-filter');
19890 var v = btn.querySelector('.scope-stat-value');
19891 if (f && v) originalStats[f] = v.textContent;
19892 });
19893
19894 function applySubmoduleStats(statsJson) {
19895 try {
19896 var s = JSON.parse(statsJson);
19897 buttons.forEach(function (btn) {
19898 var f = btn.getAttribute('data-filter');
19899 var v = btn.querySelector('.scope-stat-value');
19900 if (!v) return;
19901 if (f === 'dir') v.textContent = s.dirs;
19902 else if (f === 'file') v.textContent = s.files;
19903 else if (f === 'supported') v.textContent = s.supported;
19904 else if (f === 'skipped') v.textContent = s.skipped;
19905 else if (f === 'unsupported') v.textContent = s.unsupported;
19906 });
19907 } catch (e) {}
19908 }
19909
19910 function restoreBaseRepoStats() {
19911 buttons.forEach(function (btn) {
19912 var f = btn.getAttribute('data-filter');
19913 var v = btn.querySelector('.scope-stat-value');
19914 if (v && originalStats[f]) v.textContent = originalStats[f];
19915 });
19916 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19917 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19918 }
19919
19920 submoduleChips.forEach(function (chip) {
19921 chip.addEventListener('click', function () {
19922 var statsJson = chip.getAttribute('data-sub-stats');
19923 if (!statsJson) return;
19924 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19925 chip.classList.add('active');
19926 applySubmoduleStats(statsJson);
19927 if (baseRepoBtn) baseRepoBtn.style.display = '';
19928 });
19929 });
19930
19931 if (baseRepoBtn) {
19932 baseRepoBtn.addEventListener('click', function () {
19933 restoreBaseRepoStats();
19934 resetViewState();
19935 sortSiblingRows();
19936 applyVisibility();
19937 });
19938 }
19939
19940 buttons.forEach(function (button) {
19941 button.addEventListener("click", function () {
19942 var filterValue = button.getAttribute("data-filter") || "all";
19943 if (filterValue === "reset-view") {
19944 restoreBaseRepoStats();
19945 resetViewState();
19946 sortSiblingRows();
19947 applyVisibility();
19948 return;
19949 }
19950 activeFilter = filterValue;
19951 applyVisibility();
19952 });
19953 });
19954
19955 rows.forEach(function (row) {
19956 updateToggleGlyph(row);
19957 var toggle = row.querySelector(".tree-toggle");
19958 if (toggle) {
19959 toggle.addEventListener("click", function () {
19960 var expanded = row.getAttribute("data-expanded") !== "false";
19961 row.setAttribute("data-expanded", expanded ? "false" : "true");
19962 updateToggleGlyph(row);
19963 applyVisibility();
19964 });
19965 }
19966 });
19967
19968 actionButtons.forEach(function (button) {
19969 button.addEventListener("click", function () {
19970 var action = button.getAttribute("data-explorer-action");
19971 if (action === "expand-all") {
19972 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19973 } else if (action === "collapse-all") {
19974 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19975 } else if (action === "clear-filters") {
19976 resetViewState();
19977 }
19978 sortSiblingRows();
19979 applyVisibility();
19980 });
19981 });
19982
19983 if (filterSelect) {
19984 filterSelect.addEventListener("change", function () {
19985 activeFilter = filterSelect.value || "all";
19986 applyVisibility();
19987 });
19988 }
19989
19990 languageButtons.forEach(function (button) {
19991 button.addEventListener("click", function () {
19992 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19993 updateLanguageButtons();
19994 applyVisibility();
19995 });
19996 });
19997
19998 sortButtons.forEach(function (button) {
19999 button.addEventListener("click", function () {
20000 var sortKey = button.getAttribute("data-sort-key");
20001 if (currentSortKey === sortKey) {
20002 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20003 } else {
20004 currentSortKey = sortKey;
20005 currentSortOrder = "asc";
20006 }
20007 sortSiblingRows();
20008 applyVisibility();
20009 });
20010 });
20011
20012 if (searchInput) {
20013 searchInput.addEventListener("input", function () {
20014 searchTerm = searchInput.value.trim().toLowerCase();
20015 applyVisibility();
20016 });
20017 }
20018
20019 updateLanguageButtons();
20020 sortSiblingRows();
20021 applyVisibility();
20022 }
20023
20024 function loadPreview() {
20025 if (!previewPanel || !pathInput) return;
20026 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20027 multiRepoBlocked = false;
20028 refreshStep1Gate();
20029 if (GIT_MODE) {
20030 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>';
20031 setPreviewLoading(false);
20032 return;
20033 }
20034 var path = pathInput.value.trim();
20035 var zeroWarn = document.getElementById('zero-files-warning');
20036 if (!path) {
20037 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20038 if (zeroWarn) zeroWarn.style.display = 'none';
20039 setPreviewLoading(false);
20040 return;
20041 }
20042 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20043 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20044 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20045 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20046 var myGen = ++_previewGen;
20047 var _prevMsgs = [
20048 'Scanning directory structure\u2026',
20049 'Detecting file types\u2026',
20050 'Applying include / exclude filters\u2026',
20051 'Estimating file counts\u2026',
20052 'Building scope preview\u2026',
20053 'Almost there\u2026'
20054 ];
20055 var _prevMsgIdx = 0;
20056 var _prevStart = Date.now();
20057 previewPanel.innerHTML =
20058 '<div class="preview-loading">' +
20059 '<div class="preview-spinner"></div>' +
20060 '<div class="preview-loading-text">' +
20061 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20062 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20063 '</div></div>';
20064 var _sizeTextEl = document.getElementById('project-size-text');
20065 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20066 window._previewInterval = setInterval(function() {
20067 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20068 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20069 var ml = document.getElementById('plm');
20070 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20071 }, 1500);
20072 window._previewElapsedTimer = setInterval(function() {
20073 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20074 var el = document.getElementById('ple');
20075 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20076 }, 1000);
20077 setPreviewLoading(true);
20078 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20079 + "&include_globs=" + encodeURIComponent(includeValue)
20080 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20081 fetch(previewUrl)
20082 .then(function (response) { return response.text(); })
20083 .then(function (html) {
20084 if (myGen !== _previewGen) return;
20085 clearInterval(window._previewInterval); window._previewInterval = null;
20086 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20087 setPreviewLoading(false);
20088 previewPanel.innerHTML = html;
20089 attachPreviewInteractions();
20090 syncPythonVisibility();
20091 updateReview();
20092 setTimeout(collapseLanguagePills, 50);
20093 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20094 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20095 var sizeText = document.getElementById('project-size-text');
20096 var sizeBtn = document.getElementById('project-size-btn');
20097 // In server mode with upload sizes available, keep the compressed/original pair.
20098 if (SERVER_MODE && window._lastUploadSizes) {
20099 var us = window._lastUploadSizes;
20100 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20101 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20102 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20103 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20104 } else if (sizeText && projectSize) {
20105 sizeText.textContent = 'Project size: ' + projectSize;
20106 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20107 } else if (sizeText) {
20108 sizeText.textContent = 'Project size: \u2014';
20109 }
20110 if (zeroWarn) {
20111 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20112 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20113 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20114 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20115 if (supportedCount === 0 && fileCount > 0) {
20116 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).';
20117 zeroWarn.style.display = '';
20118 } else {
20119 zeroWarn.style.display = 'none';
20120 }
20121 }
20122 })
20123 .catch(function (err) {
20124 if (myGen !== _previewGen) return;
20125 clearInterval(window._previewInterval); window._previewInterval = null;
20126 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20127 setPreviewLoading(false);
20128 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20129 });
20130 }
20131
20132 function pickDirectory(targetInput, kind) {
20133 if (!targetInput) {
20134 showBannerToast("Directory picker: input element not found.", true);
20135 return;
20136 }
20137 if (SERVER_MODE) {
20138 if (kind === 'output') {
20139 showBannerToast(
20140 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20141 false,
20142 { top: true, icon: '\u{1F4C1}' }
20143 );
20144 return;
20145 }
20146 var inputEl = kind === 'coverage'
20147 ? document.getElementById('cov-upload-input')
20148 : document.getElementById('dir-upload-input');
20149 if (!inputEl) return;
20150 inputEl.onchange = function () {
20151 var files = inputEl.files;
20152 if (!files || files.length === 0) return;
20153 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20154 if (browseBtn) browseBtn.disabled = true;
20155
20156 function fileToBase64(file) {
20157 return new Promise(function (resolve, reject) {
20158 var reader = new FileReader();
20159 reader.onload = function () {
20160 var b64 = reader.result.split(',')[1];
20161 resolve(b64);
20162 };
20163 reader.onerror = reject;
20164 reader.readAsDataURL(file);
20165 });
20166 }
20167
20168 if (kind === 'coverage') {
20169 var f = files[0];
20170 if (previewPanel && targetInput === pathInput)
20171 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20172 fileToBase64(f).then(function (b64) {
20173 return fetch('/api/upload-file', {
20174 method: 'POST',
20175 headers: { 'Content-Type': 'application/json' },
20176 body: JSON.stringify({ filename: f.name, content: b64 })
20177 }).then(function (r) { return r.json(); });
20178 })
20179 .then(function (d) {
20180 if (d && d.tmp_path) {
20181 if (coverageInput) coverageInput.value = d.tmp_path;
20182 setCovStatus('idle');
20183 } else if (d && d.error) { showBannerToast(d.error, true); }
20184 })
20185 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20186 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20187 } else {
20188 // ── Filter to source-code files only ─────────────────────────
20189 // Binary, generated, and dependency files (node_modules, .git,
20190 // build artifacts) are skipped so they are never uploaded.
20191 var CODE_EXTS = new Set([
20192 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20193 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20194 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20195 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20196 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20197 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20198 'tf','hcl','proto','thrift','avsc','graphql','gql'
20199 ]);
20200 var codeFiles = [];
20201 for (var i = 0; i < files.length; i++) {
20202 var f = files[i];
20203 var name = f.name;
20204 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20205 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20206 codeFiles.push(f); continue;
20207 }
20208 var dot = name.lastIndexOf('.');
20209 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20210 }
20211 // Collect specific .git metadata files for server-side git detection.
20212 // These have no source extension so they are excluded by the loop above,
20213 // but the server needs them to read branch/commit/author without running git.
20214 var gitMetaFiles = [];
20215 for (var i = 0; i < files.length; i++) {
20216 var f = files[i];
20217 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20218 var gitIdx = rp.indexOf('/.git/');
20219 if (gitIdx < 0) continue;
20220 var gitRel = rp.slice(gitIdx + 1);
20221 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20222 gitRel === '.git/logs/HEAD' ||
20223 gitRel.startsWith('.git/refs/heads/') ||
20224 gitRel.startsWith('.git/refs/tags/')) {
20225 gitMetaFiles.push(f);
20226 }
20227 }
20228 var uploadFiles = codeFiles.concat(gitMetaFiles);
20229 var total = files.length;
20230 var kept = codeFiles.length;
20231 if (kept === 0) {
20232 if (previewPanel && targetInput === pathInput)
20233 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20234 if (browseBtn) browseBtn.disabled = false;
20235 inputEl.value = '';
20236 return;
20237 }
20238
20239 // ── Helper: apply upload result to UI ────────────────────────
20240 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20241 function applyUploadResult(tmpPath, sizes) {
20242 targetInput.value = tmpPath;
20243 scrollInputToEnd(targetInput);
20244 if (sizes && SERVER_MODE) {
20245 window._lastUploadSizes = sizes;
20246 // Immediately show both sizes before preview loads.
20247 var sizeText = document.getElementById('project-size-text');
20248 var sizeBtn = document.getElementById('project-size-btn');
20249 if (sizeText) {
20250 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20251 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20252 }
20253 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20254 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20255 }
20256 if (targetInput === pathInput) {
20257 updateReportTitleFromPath();
20258 autoSetOutputDir(tmpPath);
20259 fetchProjectHistory(tmpPath);
20260 loadPreview();
20261 suggestCoverageFile(tmpPath);
20262 }
20263 updateReview();
20264 if (browseBtn) browseBtn.disabled = false;
20265 inputEl.value = '';
20266 }
20267
20268 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20269 if (typeof CompressionStream !== 'undefined') {
20270 if (previewPanel && targetInput === pathInput)
20271 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20272
20273 // Build a minimal POSIX ustar tar header for a single file entry.
20274 function buildUstarHeader(filePath, fileSize) {
20275 var BLOCK = 512;
20276 var hdr = new Uint8Array(BLOCK);
20277 var enc = new TextEncoder();
20278 function wStr(off, len, s) {
20279 var b = enc.encode(s);
20280 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20281 }
20282 function wOct(off, len, val) {
20283 var s = val.toString(8);
20284 while (s.length < len - 1) s = '0' + s;
20285 wStr(off, len, s + '\0');
20286 }
20287 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20288 var name = filePath, prefix = '';
20289 if (filePath.length > 99) {
20290 var split = filePath.lastIndexOf('/', 154);
20291 if (split > 0 && filePath.length - split - 1 <= 99) {
20292 prefix = filePath.substring(0, split);
20293 name = filePath.substring(split + 1);
20294 } else { name = filePath.substring(0, 99); }
20295 }
20296 wStr(0, 100, name); // name
20297 wOct(100, 8, 0o000644); // mode
20298 wOct(108, 8, 0); // uid
20299 wOct(116, 8, 0); // gid
20300 wOct(124, 12, fileSize); // size
20301 wOct(136, 12, 0); // mtime (epoch)
20302 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20303 hdr[156] = 48; // type flag '0' = regular file
20304 wStr(157, 100, ''); // linkname
20305 wStr(257, 6, 'ustar'); // magic
20306 wStr(263, 2, '00'); // version
20307 wStr(265, 32, ''); // uname
20308 wStr(297, 32, ''); // gname
20309 wOct(329, 8, 0); // devmajor
20310 wOct(337, 8, 0); // devminor
20311 wStr(345, 155, prefix); // prefix
20312 // Compute checksum (sum of all bytes, placeholder = 32).
20313 var chk = 0;
20314 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20315 var cs = chk.toString(8);
20316 while (cs.length < 6) cs = '0' + cs;
20317 wStr(148, 8, cs + '\0 ');
20318 return hdr;
20319 }
20320
20321 // Build tar.gz one file at a time, piping through CompressionStream.
20322 // RAM usage = compressed output buffer + one file at a time.
20323 (async function () {
20324 try {
20325 var BLOCK = 512;
20326 var cs = new CompressionStream('gzip');
20327 var writer = cs.writable.getWriter();
20328 var chunks = [];
20329 var reader = cs.readable.getReader();
20330 var collecting = (async function () {
20331 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20332 })();
20333
20334 for (var i = 0; i < uploadFiles.length; i++) {
20335 var file = uploadFiles[i];
20336 var path = file.webkitRelativePath || file.name;
20337 var buf = await file.arrayBuffer();
20338 var data = new Uint8Array(buf);
20339 // Header block
20340 await writer.write(buildUstarHeader(path, data.length));
20341 // Data padded to 512-byte boundary
20342 if (data.length > 0) {
20343 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20344 var block = new Uint8Array(padded);
20345 block.set(data);
20346 await writer.write(block);
20347 }
20348 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20349 if (previewPanel && targetInput === pathInput)
20350 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20351 }
20352 }
20353 // End-of-archive: two 512-byte zero blocks
20354 await writer.write(new Uint8Array(BLOCK * 2));
20355 await writer.close();
20356 await collecting;
20357
20358 var blob = new Blob(chunks, { type: 'application/gzip' });
20359 var sizeMB = (blob.size / 1048576).toFixed(1);
20360 if (previewPanel && targetInput === pathInput)
20361 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20362
20363 var resp = await fetch('/api/upload-tarball', {
20364 method: 'POST',
20365 headers: { 'Content-Type': 'application/gzip' },
20366 body: blob
20367 });
20368 var d = await resp.json();
20369 if (d && d.tmp_path) {
20370 applyUploadResult(d.tmp_path, {
20371 compressed_bytes: d.compressed_bytes || 0,
20372 original_bytes: d.original_bytes || 0
20373 });
20374 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20375 } catch (e) {
20376 showBannerToast('Upload failed: ' + String(e), true);
20377 if (browseBtn) browseBtn.disabled = false;
20378 inputEl.value = '';
20379 }
20380 })();
20381
20382 } else {
20383 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20384 // Used only on browsers that lack CompressionStream (pre-2023).
20385 var BATCH = 200;
20386 var batches = [];
20387 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20388 var totalBatches = batches.length;
20389 if (previewPanel && targetInput === pathInput)
20390 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20391
20392 function sendBatch(idx, currentUploadId, lastTmpPath) {
20393 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20394 if (previewPanel && targetInput === pathInput && totalBatches > 1)
20395 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20396 Promise.all(batches[idx].map(function (file) {
20397 return fileToBase64(file).then(function (b64) {
20398 return { path: file.webkitRelativePath || file.name, content: b64 };
20399 });
20400 })).then(function (fileList) {
20401 var body = { files: fileList };
20402 if (currentUploadId) body.upload_id = currentUploadId;
20403 return fetch('/api/upload-directory', {
20404 method: 'POST', headers: { 'Content-Type': 'application/json' },
20405 body: JSON.stringify(body)
20406 }).then(function (r) { return r.json(); });
20407 }).then(function (d) {
20408 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20409 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20410 }).catch(function (e) {
20411 showBannerToast('Upload failed: ' + String(e), true);
20412 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20413 });
20414 }
20415 sendBatch(0, null, '');
20416 }
20417 }
20418 };
20419 inputEl.click();
20420 return;
20421 }
20422
20423 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20424 if (browseButton) browseButton.disabled = true;
20425
20426 if (previewPanel && targetInput === pathInput) {
20427 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20428 }
20429
20430 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
20431 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20432 .then(function (data) {
20433 if (data && data.selected_path) {
20434 targetInput.value = data.selected_path;
20435 scrollInputToEnd(targetInput);
20436
20437 if (targetInput === pathInput) {
20438 updateReportTitleFromPath();
20439 autoSetOutputDir(data.selected_path);
20440 fetchProjectHistory(data.selected_path);
20441 loadPreview();
20442 suggestCoverageFile(data.selected_path);
20443 }
20444
20445 updateReview();
20446 } else if (targetInput === pathInput) {
20447 loadPreview();
20448 }
20449 })
20450 .catch(function () {
20451 window.alert("Directory picker request failed.");
20452 if (previewPanel && targetInput === pathInput) {
20453 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20454 }
20455 })
20456 .finally(function () {
20457 if (browseButton) browseButton.disabled = false;
20458 });
20459 }
20460
20461 if (themeToggle) {
20462 themeToggle.addEventListener("click", function () {
20463 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20464 applyTheme(nextTheme);
20465 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20466 });
20467 }
20468
20469 stepButtons.forEach(function (button) {
20470 button.addEventListener("click", function () {
20471 var target = Number(button.getAttribute("data-step-target"));
20472 // Block jumping forward off step 1 while the preview / upload is running
20473 // or while a multi-repository selection is unacknowledged.
20474 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20475 setStep(target);
20476 });
20477 });
20478
20479 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20480 button.addEventListener("click", function () {
20481 var target = Number(button.getAttribute("data-step-target")) || 1;
20482 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20483 setStep(target);
20484 });
20485 });
20486
20487 // True when the project path is untouched from the bundled sample default.
20488 function isDefaultSamplePath() {
20489 return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20490 }
20491
20492 var defaultPathOverlay = document.getElementById("default-path-overlay");
20493 function closeDefaultPathModal() {
20494 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20495 }
20496 function openDefaultPathModal() {
20497 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20498 }
20499
20500 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20501 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20502 // that borrow the .next-step style class but carry no data-next target).
20503 if (!button.hasAttribute("data-next")) return;
20504 button.addEventListener("click", function () {
20505 // Guard step 1 → 2: block while the scope preview / upload is still running
20506 // or while a multi-repository selection is unacknowledged.
20507 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
20508 // Guard step 1 → 2: warn when the project path is still the sample default.
20509 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20510 openDefaultPathModal();
20511 return;
20512 }
20513 updateReview();
20514 setStep(Number(button.getAttribute("data-next")));
20515 });
20516 });
20517
20518 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20519 if (!button.hasAttribute("data-prev")) return;
20520 button.addEventListener("click", function () {
20521 setStep(Number(button.getAttribute("data-prev")));
20522 });
20523 });
20524
20525 // Default-sample-path confirmation modal wiring.
20526 var defaultPathProceed = document.getElementById("default-path-proceed");
20527 if (defaultPathProceed) {
20528 defaultPathProceed.addEventListener("click", function () {
20529 closeDefaultPathModal();
20530 updateReview();
20531 setStep(2);
20532 });
20533 }
20534 var defaultPathCancel = document.getElementById("default-path-cancel");
20535 if (defaultPathCancel) {
20536 defaultPathCancel.addEventListener("click", function () {
20537 closeDefaultPathModal();
20538 if (pathInput) { pathInput.focus(); pathInput.select(); }
20539 });
20540 }
20541 if (defaultPathOverlay) {
20542 defaultPathOverlay.addEventListener("click", function (e) {
20543 if (e.target === defaultPathOverlay) closeDefaultPathModal();
20544 });
20545 }
20546 document.addEventListener("keydown", function (e) {
20547 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20548 closeDefaultPathModal();
20549 }
20550 });
20551
20552 document.addEventListener("keydown", function (e) {
20553 var tag = (document.activeElement || {}).tagName || "";
20554 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20555 if (e.altKey || e.ctrlKey || e.metaKey) return;
20556 if (e.key === "ArrowRight" && currentStep < 4) {
20557 if (currentStep === 1 && step1ForwardBlocked()) return;
20558 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20559 updateReview(); setStep(currentStep + 1);
20560 }
20561 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20562 });
20563
20564 if (useSamplePath) {
20565 useSamplePath.addEventListener("click", function () {
20566 pathInput.value = "tests/fixtures/basic";
20567 updateReportTitleFromPath();
20568 autoSetOutputDir("tests/fixtures/basic");
20569 loadPreview();
20570 suggestCoverageFile("tests/fixtures/basic");
20571 });
20572 }
20573
20574 if (useDefaultOutput) {
20575 useDefaultOutput.addEventListener("click", function () {
20576 delete outputDirInput.dataset.userEdited;
20577 autoSetOutputDir(pathInput ? pathInput.value : "");
20578 updateReview();
20579 });
20580 }
20581
20582 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20583 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20584
20585 // ── Drag-and-drop directory upload (server mode only) ─────────────────
20586 // Dropping a folder onto the path field bypasses Chrome's
20587 // "Upload X files to this site?" confirmation dialog.
20588 async function readDirRecursively(dirEntry, basePath) {
20589 var reader = dirEntry.createReader();
20590 var all = [];
20591 for (;;) {
20592 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20593 if (!batch.length) break;
20594 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20595 }
20596 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20597 var out = [];
20598 for (var i = 0; i < all.length; i++) {
20599 var sub = all[i];
20600 if (sub.isFile) {
20601 var f = await new Promise(function(res) { sub.file(res); });
20602 out.push({ file: f, path: basePath + '/' + sub.name });
20603 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20604 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20605 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20606 }
20607 }
20608 return out;
20609 }
20610
20611 function setupPathDropZone() {
20612 if (!SERVER_MODE || !pathInput) return;
20613 var CODE_EXTS = new Set([
20614 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20615 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20616 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20617 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20618 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20619 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20620 ]);
20621 pathInput.addEventListener('dragover', function(e) {
20622 e.preventDefault();
20623 pathInput.classList.add('drag-over');
20624 });
20625 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20626 pathInput.addEventListener('drop', function(e) {
20627 e.preventDefault();
20628 pathInput.classList.remove('drag-over');
20629 var items = e.dataTransfer.items;
20630 if (!items || !items.length) return;
20631 var dirEntry = null;
20632 for (var i = 0; i < items.length; i++) {
20633 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20634 if (entry && entry.isDirectory) { dirEntry = entry; break; }
20635 }
20636 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20637 var btn = browsePath;
20638 if (btn) btn.disabled = true;
20639 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20640
20641 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20642 var total = allEntries.length;
20643 var codeEntries = allEntries.filter(function(e) {
20644 var n = e.file.name;
20645 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20646 var dot = n.lastIndexOf('.');
20647 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20648 });
20649 var kept = codeEntries.length;
20650 if (kept === 0) {
20651 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20652 if (btn) btn.disabled = false; return;
20653 }
20654
20655 function finish(tmpPath, sizes) {
20656 pathInput.value = tmpPath;
20657 scrollInputToEnd(pathInput);
20658 if (sizes) {
20659 window._lastUploadSizes = sizes;
20660 var sizeText = document.getElementById('project-size-text');
20661 var sizeBtn = document.getElementById('project-size-btn');
20662 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20663 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20664 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20665 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20666 }
20667 updateReportTitleFromPath();
20668 autoSetOutputDir(tmpPath);
20669 fetchProjectHistory(tmpPath);
20670 loadPreview();
20671 suggestCoverageFile(tmpPath);
20672 updateReview();
20673 if (btn) btn.disabled = false;
20674 }
20675
20676 if (typeof CompressionStream === 'undefined') {
20677 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20678 if (btn) btn.disabled = false; return;
20679 }
20680
20681 try {
20682 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20683 var BLOCK = 512;
20684 var cs = new CompressionStream('gzip');
20685 var wtr = cs.writable.getWriter();
20686 var chunks = [];
20687 var rdr = cs.readable.getReader();
20688 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20689
20690 function buildHdr(fp, sz) {
20691 var hdr = new Uint8Array(BLOCK);
20692 var enc = new TextEncoder();
20693 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]; }
20694 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20695 var nm = fp, pfx = '';
20696 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); } }
20697 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20698 for (var i = 148; i < 156; i++) hdr[i] = 32;
20699 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);
20700 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20701 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20702 return hdr;
20703 }
20704
20705 for (var i = 0; i < codeEntries.length; i++) {
20706 var ce = codeEntries[i];
20707 var buf = await ce.file.arrayBuffer();
20708 var data = new Uint8Array(buf);
20709 await wtr.write(buildHdr(ce.path, data.length));
20710 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20711 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20712 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20713 }
20714 await wtr.write(new Uint8Array(BLOCK * 2));
20715 await wtr.close();
20716 await collecting;
20717
20718 var blob = new Blob(chunks, { type: 'application/gzip' });
20719 var sizeMB = (blob.size / 1048576).toFixed(1);
20720 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20721 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20722 var d = await resp.json();
20723 if (d && d.tmp_path) {
20724 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20725 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20726 } catch (err) {
20727 showBannerToast('Upload failed: ' + String(err), true);
20728 if (btn) btn.disabled = false;
20729 }
20730 }).catch(function(err) {
20731 showBannerToast('Could not read folder: ' + String(err), true);
20732 if (btn) btn.disabled = false;
20733 });
20734 });
20735 }
20736 setupPathDropZone();
20737 if (browseCoverage) {
20738 browseCoverage.addEventListener("click", function () {
20739 pickDirectory(coverageInput || pathInput, "coverage");
20740 });
20741 }
20742
20743 function setCovStatus(state, opts) {
20744 if (!covScanStatus) return;
20745 opts = opts || {};
20746 covScanStatus.className = "cov-scan-status cov-scan-" + state;
20747 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20748 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>';
20749 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>';
20750 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>';
20751 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>';
20752 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20753 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20754 if (state === "scanning") {
20755 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20756 } else if (state === "found") {
20757 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20758 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20759 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20760 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20761 } else if (state === "hint") {
20762 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20763 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
20764 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>';
20765 } else if (state === "none") {
20766 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20767 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
20768 }
20769 html += '</div></div>';
20770 covScanStatus.innerHTML = html;
20771 if (state === "found") {
20772 var useBtn = covScanStatus.querySelector(".cov-scan-use");
20773 if (useBtn) useBtn.addEventListener("click", function () {
20774 if (coverageInput) coverageInput.value = "";
20775 covAutoFilled = false;
20776 setCovStatus("idle");
20777 });
20778 }
20779 }
20780
20781 function suggestCoverageFile(projectPath) {
20782 if (!coverageInput || !covScanStatus) return;
20783 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20784 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20785 clearTimeout(coverageSuggestTimer);
20786 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20787 setCovStatus("scanning");
20788 coverageSuggestTimer = setTimeout(function () {
20789 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20790 .then(function (r) { return r.json(); })
20791 .then(function (d) {
20792 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20793 if (!d) { setCovStatus("none"); return; }
20794 if (d.found) {
20795 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20796 setCovStatus("found", { found: d.found, tool: d.tool });
20797 } else if (d.tool && d.hint) {
20798 setCovStatus("hint", { tool: d.tool, hint: d.hint });
20799 } else {
20800 setCovStatus("none");
20801 }
20802 })
20803 .catch(function () { setCovStatus("idle"); });
20804 }, 600);
20805 }
20806
20807 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20808
20809 if (coverageInput) coverageInput.addEventListener("input", function () {
20810 covAutoFilled = false;
20811 if (!this.value.trim()) setCovStatus("idle");
20812 });
20813
20814 // ── Language pill overflow: collapse to "+N more" chip ─────────────
20815 function collapseLanguagePills() {
20816 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20817 rows.forEach(function(row) {
20818 // Remove any previous overflow chip
20819 var prev = row.querySelector('.lang-overflow-chip');
20820 if (prev) prev.remove();
20821 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20822 pills.forEach(function(p) { p.style.display = ''; });
20823 if (!pills.length) return;
20824
20825 // Measure after restoring all pills
20826 var containerRight = row.getBoundingClientRect().right;
20827 var hidden = [];
20828 for (var i = pills.length - 1; i >= 1; i--) {
20829 var rect = pills[i].getBoundingClientRect();
20830 if (rect.right > containerRight + 2) {
20831 hidden.unshift(pills[i]);
20832 pills[i].style.display = 'none';
20833 } else {
20834 break;
20835 }
20836 }
20837
20838 if (hidden.length) {
20839 var chip = document.createElement('button');
20840 chip.type = 'button';
20841 chip.className = 'language-pill lang-overflow-chip';
20842 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20843 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20844 row.appendChild(chip);
20845 }
20846 });
20847 }
20848
20849 // Run after preview loads (preview panel populates language pills)
20850 var _origLoadPreviewCb = window.__previewLoaded;
20851 document.addEventListener('previewLoaded', collapseLanguagePills);
20852 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20853 setTimeout(collapseLanguagePills, 400);
20854
20855 // ── Project history & output dir auto-set ──────────────────────────
20856 var wsOutputRoot = document.getElementById("ws-output-root");
20857 var wsScanCount = document.getElementById("ws-scan-count");
20858 var wsLastScan = document.getElementById("ws-last-scan");
20859 var historyBadge = document.getElementById("path-history-badge");
20860 var historyTimer = null;
20861
20862 var wsOutputLink = document.getElementById("ws-output-link");
20863 function syncStripOutputRoot() {
20864 var val = outputDirInput ? outputDirInput.value : "";
20865 var display = val || "project/sloc";
20866 if (wsOutputRoot) wsOutputRoot.textContent = display;
20867 if (wsOutputLink) wsOutputLink.dataset.folder = val;
20868 }
20869
20870 function scrollInputToEnd(input) {
20871 if (!input) return;
20872 // Defer so the DOM has the new value before we measure scroll width.
20873 requestAnimationFrame(function () {
20874 input.scrollLeft = input.scrollWidth;
20875 input.selectionStart = input.selectionEnd = input.value.length;
20876 });
20877 }
20878
20879 function autoSetOutputDir(projectPath) {
20880 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20881 if (GIT_MODE && GIT_OUTPUT_DIR) {
20882 outputDirInput.value = GIT_OUTPUT_DIR;
20883 scrollInputToEnd(outputDirInput);
20884 syncStripOutputRoot();
20885 updateReview();
20886 return;
20887 }
20888 if (!projectPath || !projectPath.trim()) return;
20889 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20890 outputDirInput.value = cleaned + "/sloc";
20891 scrollInputToEnd(outputDirInput);
20892 syncStripOutputRoot();
20893 updateReview();
20894 }
20895
20896 var wsBranch = document.getElementById("ws-branch");
20897
20898 function fetchProjectHistory(projectPath) {
20899 if (!projectPath || !projectPath.trim()) {
20900 if (wsScanCount) wsScanCount.textContent = "\u2014";
20901 if (wsLastScan) wsLastScan.textContent = "\u2014";
20902 if (wsBranch) wsBranch.textContent = "\u2014";
20903 if (historyBadge) historyBadge.style.display = "none";
20904 return;
20905 }
20906 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20907 .then(function (r) { return r.ok ? r.json() : null; })
20908 .then(function (data) {
20909 if (!data) return;
20910 var countStr = data.scan_count > 0
20911 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20912 : "never";
20913 var tsStr = data.last_scan_timestamp
20914 ? data.last_scan_timestamp.replace(" UTC","")
20915 : "\u2014";
20916 if (wsScanCount) wsScanCount.textContent = countStr;
20917 if (wsLastScan) wsLastScan.textContent = tsStr;
20918 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
20919 if (data.scan_count > 0) {
20920 if (historyBadge) {
20921 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20922 historyBadge.textContent = data.scan_count + " previous scan" +
20923 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20924 "Last: " + (data.last_scan_timestamp || "\u2014") +
20925 " \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.";
20926 historyBadge.className = "path-history-badge found";
20927 historyBadge.style.display = "";
20928 }
20929 } else {
20930 if (historyBadge) historyBadge.style.display = "none";
20931 }
20932 })
20933 .catch(function () {});
20934 }
20935
20936 function onPathChange() {
20937 var val = pathInput ? pathInput.value : "";
20938 // Discard stale upload sizes when the user edits the path manually.
20939 window._lastUploadSizes = null;
20940 updateReportTitleFromPath();
20941 autoSetOutputDir(val);
20942 updateSidebarSummary();
20943 clearTimeout(historyTimer);
20944 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20945 if (previewTimer) clearTimeout(previewTimer);
20946 previewTimer = setTimeout(loadPreview, 280);
20947 suggestCoverageFile(val);
20948 }
20949
20950 if (pathInput) {
20951 pathInput.addEventListener("input", onPathChange);
20952 }
20953
20954 if (outputDirInput) {
20955 outputDirInput.addEventListener("input", function () {
20956 outputDirInput.dataset.userEdited = "1";
20957 syncStripOutputRoot();
20958 updateReview();
20959 });
20960 }
20961
20962 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20963 if (!node) return;
20964 node.addEventListener("input", function () {
20965 updateReview();
20966 if (previewTimer) clearTimeout(previewTimer);
20967 previewTimer = setTimeout(loadPreview, 280);
20968 });
20969 });
20970
20971 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20972 var node = document.getElementById(id);
20973 if (node) node.addEventListener("change", updateReview);
20974 });
20975
20976 if (reportTitleInput) {
20977 reportTitleInput.addEventListener("input", function () {
20978 reportTitleTouched = reportTitleInput.value.trim().length > 0;
20979 updateReportTitleFromPath();
20980 updateReview();
20981 });
20982 }
20983
20984 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20985 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20986 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20987 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20988
20989 if (coverageInput) {
20990 coverageInput.addEventListener("input", function () {
20991 if (coverageInput.value.trim()) setCovStatus("idle");
20992 });
20993 }
20994
20995 if (form && loading && submitButton) {
20996 form.addEventListener("submit", function (e) {
20997 e.preventDefault();
20998 submitButton.disabled = true;
20999 submitButton.textContent = "Scanning...";
21000 startAsyncAnalysis(new FormData(form));
21001 });
21002 }
21003
21004 function openPath(folder) {
21005 if (!folder) return;
21006 fetch('/open-path?path=' + encodeURIComponent(folder))
21007 .then(function (r) { return r.json(); })
21008 .then(function (d) {
21009 if (d && d.server_mode_disabled)
21010 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21011 })
21012 .catch(function () {});
21013 }
21014
21015 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21016 btn.addEventListener('click', function () {
21017 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21018 });
21019 });
21020
21021 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21022 if (wsOutputLink) {
21023 wsOutputLink.addEventListener('click', function () {
21024 openPath(wsOutputLink.dataset.folder || '');
21025 });
21026 }
21027
21028 loadSavedTheme();
21029 updateMixedPolicyUI();
21030 updatePythonDocstringUI();
21031 applyScanPreset();
21032 updatePresetDescriptions();
21033 applyArtifactPreset();
21034 updateReview();
21035 updateScrollProgress(); // initialise bar to 0% (step 1)
21036 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21037 onPathChange(); // seed output dir, history badge, and preview from initial path
21038 updateStepNav(1);
21039
21040 // Restore step from URL hash on initial load (e.g., back-forward cache)
21041 (function() {
21042 var hashMatch = location.hash.match(/^#step([1-4])$/);
21043 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21044 })();
21045
21046 (function randomizeWatermarks() {
21047 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21048 if (!wms.length) return;
21049 var placed = [];
21050 function tooClose(top, left) {
21051 for (var i = 0; i < placed.length; i++) {
21052 var dt = Math.abs(placed[i][0] - top);
21053 var dl = Math.abs(placed[i][1] - left);
21054 if (dt < 16 && dl < 12) return true;
21055 }
21056 return false;
21057 }
21058 function pick(leftBand) {
21059 for (var attempt = 0; attempt < 50; attempt++) {
21060 var top = Math.random() * 88 + 2;
21061 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21062 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21063 }
21064 var top = Math.random() * 88 + 2;
21065 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21066 placed.push([top, left]);
21067 return [top, left];
21068 }
21069 var half = Math.floor(wms.length / 2);
21070 wms.forEach(function (img, i) {
21071 var pos = pick(i < half);
21072 var size = Math.floor(Math.random() * 80 + 110);
21073 var rot = (Math.random() * 360).toFixed(1);
21074 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21075 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;
21076 });
21077 })();
21078
21079 (function spawnCodeParticles() {
21080 var container = document.getElementById('code-particles');
21081 if (!container) return;
21082 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'];
21083 for (var i = 0; i < 38; i++) {
21084 (function(idx) {
21085 var el = document.createElement('span');
21086 el.className = 'code-particle';
21087 el.textContent = snippets[idx % snippets.length];
21088 var left = Math.random() * 94 + 2;
21089 var top = Math.random() * 88 + 6;
21090 var dur = (Math.random() * 10 + 9).toFixed(1);
21091 var delay = (Math.random() * 18).toFixed(1);
21092 var rot = (Math.random() * 26 - 13).toFixed(1);
21093 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21094 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';
21095 container.appendChild(el);
21096 })(i);
21097 }
21098 })();
21099 })();
21100 </script>
21101 <script nonce="{{ csp_nonce }}">
21102 (function () {
21103 var raw = {{ prefill_json|safe }};
21104 if (!raw || typeof raw !== 'object' || !raw.path) return;
21105 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21106 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21107 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21108 setVal('path', raw.path || '');
21109 setVal('include_globs', raw.include_globs || '');
21110 setVal('exclude_globs', raw.exclude_globs || '');
21111 setVal('output_dir', raw.output_dir || '');
21112 setVal('report_title', raw.report_title || '');
21113 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21114 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21115 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21116 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21117 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21118 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21119 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21120 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21121 setChecked('generate_html', raw.generate_html !== false);
21122 setChecked('generate_pdf', !!raw.generate_pdf);
21123 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21124 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21125 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21126 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21127 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21128 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21129 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21130 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21131 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21132 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21133 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21134 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21135 // Trigger dynamic UI updates after pre-fill.
21136 setTimeout(function () {
21137 var pathEl = document.getElementById('path');
21138 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21139 var policyEl = document.getElementById('mixed_line_policy');
21140 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21141 }, 80);
21142 })();
21143 </script>
21144 <script nonce="{{ csp_nonce }}">
21145 (function(){
21146 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'}];
21147 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);});}
21148 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21149 function init(){
21150 var btn=document.getElementById('settings-btn');if(!btn)return;
21151 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21152 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>';
21153 document.body.appendChild(m);
21154 var g=document.getElementById('scheme-grid');
21155 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);});
21156 var cl=document.getElementById('settings-close');
21157 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
21158 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');});
21159 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21160 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21161 }
21162 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21163 }());
21164 </script>
21165 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21166 <div class="wb-ftip-arrow"></div>
21167 <span id="wb-ftip-text"></span>
21168 </div>
21169 <script nonce="{{ csp_nonce }}">(function(){
21170 var tip=document.getElementById('wb-ftip');
21171 var txt=document.getElementById('wb-ftip-text');
21172 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21173 if(!tip||!txt)return;
21174 function pos(el){
21175 var r=el.getBoundingClientRect();
21176 tip.style.display='block';
21177 var tw=tip.offsetWidth;
21178 var lx=r.left+r.width/2-tw/2;
21179 if(lx<8)lx=8;
21180 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21181 tip.style.left=lx+'px';
21182 tip.style.top=(r.bottom+8)+'px';
21183 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';}
21184 }
21185 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21186 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21187 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21188 });
21189 window.addEventListener('blur',function(){tip.style.display='none';});
21190 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21191 })();
21192 (function(){
21193 function fixArtifactHintSpacing(){
21194 var grid=document.querySelector('.artifact-grid');
21195 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21196 }
21197 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21198 }());
21199 (function(){
21200 var dot=document.getElementById('status-dot');
21201 var pingEl=document.getElementById('server-ping-ms');
21202 var tipEl=document.getElementById('server-tip-ping');
21203 var fm=document.getElementById('footer-mode');
21204 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)';}}
21205 function doPing(){
21206 var t0=performance.now();
21207 fetch('/healthz',{cache:'no-store'})
21208 .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);})
21209 .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)';}});
21210 }
21211 doPing();
21212 setInterval(doPing,5000);
21213 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');}
21214 })();
21215 </script>
21216 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21217 <footer class="site-footer">
21218 local code analysis - metrics, history and reports
21219 · <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>
21220 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21221 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21222 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21223 · <a href="/api-docs" rel="noopener">REST API</a>
21224 </footer>
21225</body>
21226</html>
21227"##,
21228 ext = "html"
21229)]
21230struct IndexTemplate {
21231 version: &'static str,
21232 prefill_json: String,
21233 csp_nonce: String,
21234 git_repo: String,
21235 git_ref: String,
21236 git_label_json: String,
21237 git_output_dir_json: String,
21238 server_mode: bool,
21239}
21240
21241#[derive(Template)]
21244#[template(
21245 source = r##"
21246<!doctype html>
21247<html lang="en">
21248<head>
21249 <meta charset="utf-8">
21250 <meta name="viewport" content="width=device-width, initial-scale=1">
21251 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21252 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21253 <script type="application/ld+json">
21254 {
21255 "@context": "https://schema.org",
21256 "@type": "SoftwareApplication",
21257 "name": "oxide-sloc",
21258 "applicationCategory": "DeveloperApplication",
21259 "operatingSystem": "Windows, Linux",
21260 "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.",
21261 "softwareVersion": "{{ version }}",
21262 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21263 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21264 "url": "https://github.com/oxide-sloc/oxide-sloc",
21265 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21266 "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",
21267 "programmingLanguage": "Rust",
21268 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21269 }
21270 </script>
21271 <style nonce="{{ csp_nonce }}">
21272 :root {
21273 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21274 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21275 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21276 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21277 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21278 }
21279 body.dark-theme {
21280 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21281 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21282 }
21283 *{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;}
21284 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21285 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21286 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21287 .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;}
21288 @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));}}
21289 .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);}
21290 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21291 .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));}
21292 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21293 .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;}
21294 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21295 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21296 @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; } }
21297 .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;}
21298 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21299 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21300 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21301 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21302 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21303 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
21304 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21305 .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);}
21306 .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;}
21307 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21308 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21309 .settings-modal-body{padding:14px 16px 16px;}
21310 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21311 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21312 .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;}
21313 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21314 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21315 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21316 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21317 .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;}
21318 .tz-select:focus{border-color:var(--oxide);}
21319 .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;}
21320 .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;}
21321 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21322 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21323 .hero{text-align:center;margin:0 auto 18px;}
21324 .hero-logo-wrap{display:inline-block;cursor:default;}
21325 .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;}
21326 .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;}
21327 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21328 .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;}
21329 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%);}
21330 .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;
21331 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21332 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21333 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;}
21334 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21335 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21336 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;}
21337 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21338 .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;}
21339 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21340 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21341 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21342 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21343 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21344 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21345 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21346 .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;}
21347 .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;}
21348 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21349 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21350 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21351 .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);}
21352 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21353 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21354 .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);}
21355 .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);}
21356 .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);}
21357 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21358 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21359 .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;}
21360 body.dark-theme .action-card-cta{color:var(--oxide);}
21361 .action-card.view .action-card-cta{color:var(--accent-2);}
21362 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21363 .action-card.compare .action-card-cta{color:#7c3aed;}
21364 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21365 .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);}
21366 .action-card.git-tools .action-card-cta{color:#15803d;}
21367 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21368 .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);}
21369 .action-card.trend .action-card-cta{color:#0e7490;}
21370 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21371 .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);}
21372 .action-card.automation .action-card-cta{color:#b45309;}
21373 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21374 .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);}
21375 .action-card.test-metrics .action-card-cta{color:#be185d;}
21376 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21377 .action-card:hover .action-card-cta{gap:12px;}
21378 .action-card.card-split{flex-direction:row;align-items:stretch;}
21379 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21380 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21381 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21382 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21383 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21384 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21385 .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;}
21386 .ac-badge.active{opacity:1;}
21387 .ac-badge.github{border-color:#555;color:#555;}
21388 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21389 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21390 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21391 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21392 body.dark-theme .ac-right-row{color:var(--muted);}
21393 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21394 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21395 .divider{height:1px;background:var(--line);margin:32px 0;}
21396 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21397 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21398 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21399 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21400 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21401 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21402 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21403 body.dark-theme .info-chip-val{color:var(--oxide);}
21404 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21405 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21406 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21407 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21408 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21409 border:6px solid transparent;border-top-color:var(--text);}
21410 .info-chip:hover .info-chip-tip{display:block;}
21411 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21412 .chip-slide.fading{filter:blur(5px);opacity:0;}
21413 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21414 .site-footer a{color:var(--muted);}
21415 .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;}
21416 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21417 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21418 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21419 .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;}
21420 .lan-badge.local{background:var(--oxide-2);}
21421 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21422 .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);}
21423 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21424 .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;}
21425 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21426 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21427 .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;}
21428 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21429 .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;}
21430 .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);}
21431 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21432 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21433 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21434 .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;}
21435 @media (max-height: 1100px) {
21436 .page{padding-top:10px;}
21437 .hero{margin-bottom:10px;}
21438 .hero-logo{width:54px;height:60px;}
21439 .hero-logo-shadow{width:42px;}
21440 .hero-title{font-size:28px;}
21441 .hero-subtitle{font-size:13px;}
21442 .card-sections{gap:12px;margin-bottom:6px;}
21443 .card-section-grid-2,.card-section-grid-3{gap:10px;}
21444 .action-card{padding:8px 15px 8px;}
21445 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21446 .action-card-icon svg{width:18px;height:18px;}
21447 .action-card-title{font-size:13px;}
21448 .action-card-desc{font-size:11px;margin-bottom:6px;}
21449 .action-card-cta{font-size:11px;}
21450 .ac-right-row{font-size:11px;}
21451 .divider{margin:14px 0;}
21452 .info-strip{gap:7px;margin-bottom:8px;}
21453 .info-chip{padding:7px 10px;}
21454 .info-chip-val{font-size:13px;}
21455 .info-chip-label{font-size:9px;}
21456 .site-footer{padding:8px 24px;font-size:12px;}
21457 .lan-local-hint{margin-top:8px;}
21458 }
21459 @media (max-height: 850px) {
21460 .page{padding-top:6px;}
21461 .hero{margin-bottom:6px;}
21462 .hero-logo{width:42px;height:46px;}
21463 .hero-title{font-size:22px;}
21464 .hero-subtitle{font-size:12px;}
21465 .card-sections{gap:10px;}
21466 .action-card-desc{margin-bottom:4px;}
21467 .divider{margin:8px 0;}
21468 .info-strip{margin-bottom:6px;}
21469 .lan-local-hint{margin-top:10px;}
21470 }
21471 </style>
21472</head>
21473<body>
21474 <div class="background-watermarks" aria-hidden="true">
21475 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21476 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21477 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21478 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21479 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21480 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21481 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21482 </div>
21483 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21484 <div class="top-nav">
21485 <div class="top-nav-inner">
21486 <a class="brand" href="/">
21487 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21488 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21489 </a>
21490 <div class="nav-right">
21491 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21492 <div class="nav-dropdown">
21493 <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>
21494 <div class="nav-dropdown-menu">
21495 <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>
21496 </div>
21497 </div>
21498 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21499 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21500 <div class="nav-dropdown">
21501 <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>
21502 <div class="nav-dropdown-menu">
21503 <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>
21504 </div>
21505 </div>
21506 <div class="server-status-wrap" id="server-status-wrap">
21507 <div class="nav-pill server-online-pill" id="server-status-pill">
21508 <span class="status-dot" id="status-dot"></span>
21509 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21510 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21511 </div>
21512 <div class="server-status-tip">
21513 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21514 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21515 </div>
21516 </div>
21517 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21518 <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>
21519 </button>
21520 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21521 <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>
21522 <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>
21523 </button>
21524 </div>
21525 </div>
21526 </div>
21527
21528 <div class="page">
21529 <div class="hero">
21530 <div class="hero-logo-wrap" id="hero-logo-wrap">
21531 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21532 </div>
21533 <div class="hero-logo-shadow"></div>
21534 <div class="hero-title-wrap">
21535 <div class="hero-title-aura" aria-hidden="true"></div>
21536 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21537 </div>
21538 <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>
21539 </div>
21540
21541 <div class="card-sections">
21542
21543 <div>
21544 <div class="card-section-label">Analysis</div>
21545 <div class="card-section-grid-2">
21546 <a class="action-card scan card-split" href="/scan-setup">
21547 <div class="action-card-left">
21548 <div class="action-card-icon">
21549 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21550 </div>
21551 <div class="action-card-title">Scan Project</div>
21552 <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>
21553 <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>
21554 </div>
21555 <div class="action-card-sep"></div>
21556 <div class="action-card-right">
21557 <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>
21558 <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>
21559 <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>
21560 <div class="ac-right-stat" id="acp-scan-stat"></div>
21561 </div>
21562 </a>
21563 <a class="action-card test-metrics card-split" href="/test-metrics">
21564 <div class="action-card-left">
21565 <div class="action-card-icon">
21566 <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>
21567 </div>
21568 <div class="action-card-title">Test Metrics</div>
21569 <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>
21570 <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>
21571 </div>
21572 <div class="action-card-sep"></div>
21573 <div class="action-card-right">
21574 <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>
21575 <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>
21576 <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>
21577 <div class="ac-right-stat" id="acp-test-stat"></div>
21578 </div>
21579 </a>
21580 </div>
21581 </div>
21582
21583 <div>
21584 <div class="card-section-label">Reports & Insights</div>
21585 <div class="card-section-grid-3">
21586 <a class="action-card view" href="/view-reports">
21587 <div class="action-card-icon">
21588 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21589 </div>
21590 <div class="action-card-title">View Reports</div>
21591 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21592 <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>
21593 </a>
21594 <a class="action-card compare" href="/compare-scans">
21595 <div class="action-card-icon">
21596 <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>
21597 </div>
21598 <div class="action-card-title">Compare Scans</div>
21599 <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>
21600 <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>
21601 </a>
21602 <a class="action-card trend" href="/trend-reports">
21603 <div class="action-card-icon">
21604 <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>
21605 </div>
21606 <div class="action-card-title">Trend Report</div>
21607 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21608 <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>
21609 </a>
21610 </div>
21611 </div>
21612
21613 <div>
21614 <div class="card-section-label">Developer Tools</div>
21615 <div class="card-section-grid-2">
21616 <a class="action-card git-tools card-split" href="/git-browser">
21617 <div class="action-card-left">
21618 <div class="action-card-icon">
21619 <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>
21620 </div>
21621 <div class="action-card-title">Git Browser</div>
21622 <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>
21623 <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>
21624 </div>
21625 <div class="action-card-sep"></div>
21626 <div class="action-card-right">
21627 <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>
21628 <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>
21629 <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>
21630 </div>
21631 </a>
21632 <a class="action-card automation card-split" href="/integrations">
21633 <div class="action-card-left">
21634 <div class="action-card-icon">
21635 <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>
21636 </div>
21637 <div class="action-card-title">Integrations</div>
21638 <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>
21639 <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>
21640 </div>
21641 <div class="action-card-sep"></div>
21642 <div class="action-card-right">
21643 <div class="ac-badges-grid">
21644 <span class="ac-badge github" id="acp-gh">GitHub</span>
21645 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
21646 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
21647 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21648 </div>
21649 <div class="ac-right-stat" id="acp-int-stat"></div>
21650 </div>
21651 </a>
21652 </div>
21653 </div>
21654
21655 </div>
21656
21657 {% if server_mode %}
21658 <div class="lan-card server">
21659 <div class="lan-card-header">
21660 <span class="lan-badge">LAN server</span>
21661 Accessible on your network
21662 </div>
21663 {% if let Some(ip) = lan_ip %}
21664 <div class="lan-url-row">
21665 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21666 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21667 <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>
21668 Copy URL
21669 </button>
21670 </div>
21671 <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>
21672 {% if has_api_key %}
21673 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
21674 {% endif %}
21675 {% else %}
21676 <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>
21677 {% endif %}
21678 </div>
21679 {% endif %}
21680
21681 <div class="divider"></div>
21682
21683 <div class="info-strip">
21684 <div class="info-chip">
21685 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21686 <div class="chip-slide">
21687 <div class="info-chip-val">60</div>
21688 <div class="info-chip-label">Languages</div>
21689 </div>
21690 </div>
21691 <div class="info-chip">
21692 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21693 <div class="chip-slide">
21694 <div class="info-chip-val">100%</div>
21695 <div class="info-chip-label">Self-contained</div>
21696 </div>
21697 </div>
21698 <div class="info-chip">
21699 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21700 <div class="chip-slide">
21701 <div class="info-chip-val">HTML+PDF</div>
21702 <div class="info-chip-label">Exportable reports</div>
21703 </div>
21704 </div>
21705 <div class="info-chip">
21706 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21707 <div class="chip-slide">
21708 <div class="info-chip-val">Webhook</div>
21709 <div class="info-chip-label">3 platforms</div>
21710 </div>
21711 </div>
21712 <div class="info-chip">
21713 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21714 <div class="chip-slide">
21715 <div class="info-chip-val">IEEE</div>
21716 <div class="info-chip-label">1045-1992</div>
21717 </div>
21718 </div>
21719 </div>
21720
21721 {% if lan_ip.is_none() %}
21722 <div class="lan-local-hint">
21723 <strong>Want teammates on the same network to access this?</strong><br>
21724 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
21725 </div>
21726 {% endif %}
21727 </div>
21728
21729 <footer class="site-footer">
21730 local code analysis - metrics, history and reports
21731 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21732 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21733 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21734 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21735 · <a href="/api-docs" rel="noopener">REST API</a>
21736 </footer>
21737
21738 <script nonce="{{ csp_nonce }}">
21739 (function () {
21740 var storageKey = 'oxide-sloc-theme';
21741 var body = document.body;
21742 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21743 var toggle = document.getElementById('theme-toggle');
21744 if (toggle) toggle.addEventListener('click', function () {
21745 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21746 body.classList.toggle('dark-theme', next === 'dark');
21747 try { localStorage.setItem(storageKey, next); } catch(e) {}
21748 });
21749 var copyBtn = document.getElementById('lan-copy-btn');
21750 if (copyBtn) copyBtn.addEventListener('click', function() {
21751 var btn = this;
21752 var el = document.getElementById('lan-url-val');
21753 if (!el) return;
21754 var url = el.textContent.trim();
21755 if (navigator.clipboard) {
21756 navigator.clipboard.writeText(url).then(function() {
21757 var orig = btn.innerHTML;
21758 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!';
21759 setTimeout(function() { btn.innerHTML = orig; }, 1800);
21760 });
21761 }
21762 });
21763 (function randomizeWatermarks() {
21764 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21765 if (!wms.length) return;
21766 var placed = [];
21767 function tooClose(top, left) {
21768 for (var i = 0; i < placed.length; i++) {
21769 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21770 if (dt < 16 && dl < 12) return true;
21771 }
21772 return false;
21773 }
21774 function pick(leftBand) {
21775 for (var attempt = 0; attempt < 50; attempt++) {
21776 var top = Math.random() * 88 + 2;
21777 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21778 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21779 }
21780 var top = Math.random() * 88 + 2;
21781 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21782 placed.push([top, left]); return [top, left];
21783 }
21784 var half = Math.floor(wms.length / 2);
21785 wms.forEach(function (img, i) {
21786 var pos = pick(i < half);
21787 var size = Math.floor(Math.random() * 100 + 120);
21788 var rot = (Math.random() * 360).toFixed(1);
21789 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21790 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;
21791 });
21792 })();
21793
21794 (function spawnCodeParticles() {
21795 var container = document.getElementById('code-particles');
21796 if (!container) return;
21797 var snippets = [
21798 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21799 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21800 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21801 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21802 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21803 ];
21804 var count = 38;
21805 for (var i = 0; i < count; i++) {
21806 (function(idx) {
21807 var el = document.createElement('span');
21808 el.className = 'code-particle';
21809 var text = snippets[idx % snippets.length];
21810 el.textContent = text;
21811 var left = Math.random() * 94 + 2;
21812 var top = Math.random() * 88 + 6;
21813 var dur = (Math.random() * 10 + 9).toFixed(1);
21814 var delay = (Math.random() * 18).toFixed(1);
21815 var rot = (Math.random() * 26 - 13).toFixed(1);
21816 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21817 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21818 + '--rot:' + rot + 'deg;--op:' + op + ';'
21819 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21820 container.appendChild(el);
21821 })(i);
21822 }
21823 })();
21824 (function heroAnimations() {
21825 var sub = document.getElementById('hero-subtitle');
21826 if (sub) {
21827 var full = sub.textContent.trim();
21828 sub.textContent = '';
21829 sub.style.opacity = '1';
21830 var cursor = document.createElement('span');
21831 cursor.className = 'hero-cursor';
21832 sub.appendChild(cursor);
21833 var i = 0;
21834 setTimeout(function() {
21835 var iv = setInterval(function() {
21836 if (i < full.length) {
21837 sub.insertBefore(document.createTextNode(full[i]), cursor);
21838 i++;
21839 } else {
21840 clearInterval(iv);
21841 setTimeout(function() {
21842 cursor.style.transition = 'opacity 1s ease';
21843 cursor.style.opacity = '0';
21844 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21845 }, 2400);
21846 }
21847 }, 11);
21848 }, 374);
21849 }
21850 })();
21851 (function logoBob() {
21852 var logo = document.querySelector('.hero-logo');
21853 var shadow = document.querySelector('.hero-logo-shadow');
21854 if (!logo) return;
21855 var cycleStart = null, cycleDur = 3600;
21856 var peakY = -14, peakScale = 1.07, peakRot = 0;
21857 function newCycle() {
21858 cycleDur = 3000 + Math.random() * 1840;
21859 peakY = -(9 + Math.random() * 13.8);
21860 peakScale = 1.04 + Math.random() * 0.081;
21861 peakRot = (Math.random() * 11.5 - 5.75);
21862 }
21863 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21864 newCycle();
21865 function frame(ts) {
21866 if (cycleStart === null) cycleStart = ts;
21867 var t = (ts - cycleStart) / cycleDur;
21868 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21869 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21870 var y = peakY * phase;
21871 var sc = 1 + (peakScale - 1) * phase;
21872 var rot = peakRot * Math.sin(Math.PI * phase);
21873 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21874 if (shadow) {
21875 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21876 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21877 }
21878 requestAnimationFrame(frame);
21879 }
21880 requestAnimationFrame(frame);
21881 })();
21882 (function mouseEffects() {
21883 var heroTitle = document.getElementById('hero-title');
21884 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21885 function tick() {
21886 raf = null;
21887 if (heroTitle) {
21888 var r = heroTitle.getBoundingClientRect();
21889 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21890 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21891 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21892 }
21893 }
21894 document.addEventListener('mousemove', function(e) {
21895 mx = e.clientX; my = e.clientY;
21896 if (!raf) raf = requestAnimationFrame(tick);
21897 });
21898 document.addEventListener('mouseleave', function() {
21899 if (heroTitle) {
21900 heroTitle.style.transition = 'transform 0.5s ease';
21901 heroTitle.style.transform = '';
21902 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21903 }
21904 });
21905 document.querySelectorAll('.action-card').forEach(function(card) {
21906 card.addEventListener('mousemove', function(e) {
21907 var rect = card.getBoundingClientRect();
21908 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21909 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21910 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21911 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21912 });
21913 card.addEventListener('mouseleave', function() {
21914 card.style.transition = '';
21915 card.style.transform = '';
21916 });
21917 });
21918 })();
21919 (function chipSlideshow() {
21920 var slides = [
21921 [{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'}],
21922 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21923 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21924 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21925 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21926 ];
21927 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21928 var indices = [0,0,0,0,0];
21929 var paused = [false,false,false,false,false];
21930 chips.forEach(function(chip, i) {
21931 chip.addEventListener('mouseenter', function() { paused[i] = true; });
21932 chip.addEventListener('mouseleave', function() { paused[i] = false; });
21933 });
21934 function advance(i) {
21935 if (paused[i]) return;
21936 var chip = chips[i];
21937 var inner = chip.querySelector('.chip-slide');
21938 if (!inner) return;
21939 inner.classList.add('fading');
21940 setTimeout(function() {
21941 indices[i] = (indices[i] + 1) % slides[i].length;
21942 var s = slides[i][indices[i]];
21943 chip.querySelector('.info-chip-val').textContent = s.v;
21944 chip.querySelector('.info-chip-label').textContent = s.l;
21945 inner.classList.remove('fading');
21946 }, 720);
21947 }
21948 setInterval(function() {
21949 chips.forEach(function(chip, i) { advance(i); });
21950 }, 6000);
21951 })();
21952 (function cardLiveData() {
21953 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21954 var el = document.getElementById('acp-scan-stat');
21955 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21956 }).catch(function(){});
21957 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21958 var el = document.getElementById('acp-test-stat');
21959 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21960 }).catch(function(){});
21961 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21962 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21963 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21964 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21965 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21966 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21967 var stat = document.getElementById('acp-int-stat');
21968 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21969 }).catch(function(){});
21970 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21971 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21972 }).catch(function(){});
21973 })();
21974 })();
21975 </script>
21976 <script nonce="{{ csp_nonce }}">
21977 (function(){
21978 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'}];
21979 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);});}
21980 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21981 function init(){
21982 var btn=document.getElementById('settings-btn');if(!btn)return;
21983 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21984 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>';
21985 document.body.appendChild(m);
21986 var g=document.getElementById('scheme-grid');
21987 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);});
21988 var cl=document.getElementById('settings-close');
21989 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
21990 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');});
21991 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21992 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21993 }
21994 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21995 }());
21996 </script>
21997 <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>
21998</body>
21999</html>
22000"##,
22001 ext = "html"
22002)]
22003struct SplashTemplate {
22004 csp_nonce: String,
22005 server_mode: bool,
22006 lan_ip: Option<String>,
22007 port: u16,
22008 version: &'static str,
22009 has_api_key: bool,
22010}
22011
22012#[derive(Template)]
22015#[template(
22016 source = r##"
22017<!doctype html>
22018<html lang="en">
22019<head>
22020 <meta charset="utf-8">
22021 <meta name="viewport" content="width=device-width, initial-scale=1">
22022 <title>OxideSLOC — Start a Scan</title>
22023 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22024 <style nonce="{{ csp_nonce }}">
22025 :root {
22026 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22027 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22028 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22029 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22030 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22031 }
22032 body.dark-theme {
22033 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22034 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22035 }
22036 *{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;}
22037 .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);}
22038 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22039 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22040 .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));}
22041 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22042 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22043 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22044 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22045 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22046 @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; } }
22047 .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;}
22048 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22049 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22050 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22051 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22052 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22053 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
22054 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22055 .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);}
22056 .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;}
22057 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22058 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22059 .settings-modal-body{padding:14px 16px 16px;}
22060 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22061 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22062 .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;}
22063 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22064 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22065 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22066 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22067 .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;}
22068 .tz-select:focus{border-color:var(--oxide);}
22069 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22070 .page-header{text-align:center;margin-bottom:16px;}
22071 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22072 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22073 /* Cards */
22074 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22075 .option-card-wrap{position:relative;}
22076 .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;}
22077 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22078 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22079 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22080 .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;}
22081 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22082 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22083 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22084 .card-top-row{display:flex;align-items:center;gap:20px;}
22085 /* Two-column layout inside each card */
22086 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22087 .card-left{display:flex;align-items:flex-start;min-width:0;}
22088 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22089 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22090 .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);}
22091 .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);}
22092 .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);}
22093 .card-text{min-width:0;}
22094 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22095 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22096 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22097 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22098 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22099 /* Right CTA column */
22100 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22101 .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;}
22102 /* Re-scan count badge */
22103 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22104 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22105 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22106 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22107 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22108 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22109 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22110 body.dark-theme .btn-secondary{color:var(--oxide);}
22111 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22112 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22113 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22114 .file-input-wrap{position:relative;width:100%;}
22115 .file-input-wrap .btn{width:100%;}
22116 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22117 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22118 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22119 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22120 .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;}
22121 @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));}}
22122 /* Recent list (card 3 — full-width section below header) */
22123 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22124 .recent-list{display:flex;flex-direction:column;gap:8px;}
22125 .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;}
22126 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22127 .recent-item-info{flex:1;min-width:0;}
22128 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22129 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22130 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22131 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22132 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22133 .site-footer a{color:var(--muted);}
22134 @media(max-width:680px){
22135 .card-body{grid-template-columns:1fr;}
22136 .card-right{flex-direction:row;flex-wrap:wrap;}
22137 .btn{flex:1;}
22138 }
22139 .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;}
22140 .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;}
22141 .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;}
22142 </style>
22143</head>
22144<body>
22145 <div class="background-watermarks" aria-hidden="true">
22146 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22147 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22148 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22149 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22150 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22151 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22152 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22153 </div>
22154 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22155 <div class="top-nav">
22156 <div class="top-nav-inner">
22157 <a class="brand" href="/">
22158 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22159 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22160 </a>
22161 <div class="nav-right">
22162 <a class="nav-pill" href="/">Home</a>
22163 <div class="nav-dropdown">
22164 <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>
22165 <div class="nav-dropdown-menu">
22166 <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>
22167 </div>
22168 </div>
22169 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22170 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22171 <div class="nav-dropdown">
22172 <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>
22173 <div class="nav-dropdown-menu">
22174 <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>
22175 </div>
22176 </div>
22177 <div class="server-status-wrap" id="server-status-wrap">
22178 <div class="nav-pill server-online-pill" id="server-status-pill">
22179 <span class="status-dot" id="status-dot"></span>
22180 <span id="server-status-label">Server</span>
22181 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22182 </div>
22183 <div class="server-status-tip">
22184 OxideSLOC is running — accessible on your network.
22185 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22186 </div>
22187 </div>
22188 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22189 <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>
22190 </button>
22191 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22192 <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>
22193 <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>
22194 </button>
22195 </div>
22196 </div>
22197 </div>
22198
22199 <div class="page">
22200 <div class="page-header">
22201 <h1>How would you like to scan?</h1>
22202 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22203 </div>
22204
22205 <div class="option-grid">
22206
22207 <!-- Option 1: New scan -->
22208 <div class="option-card-wrap">
22209 <div class="option-card">
22210 <div class="option-icon new-scan">
22211 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22212 </div>
22213 <div class="card-body">
22214 <div class="card-left">
22215 <div class="card-text">
22216 <div class="option-title">Start a new scan</div>
22217 <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>
22218 <ul class="feature-list">
22219 <li>Live project scope preview before you run</li>
22220 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22221 <li>HTML, PDF, and JSON output — your choice</li>
22222 </ul>
22223 </div>
22224 </div>
22225 <div class="card-right">
22226 <a class="btn btn-primary" href="/scan">
22227 Configure & scan
22228 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22229 </a>
22230 <p class="card-tip">Full 4-step setup · all options</p>
22231 </div>
22232 </div>
22233 </div>
22234 </div>
22235
22236 <!-- Option 2: Load from config file -->
22237 <div class="option-card-wrap">
22238 <div class="option-card">
22239 <div class="option-icon load-config">
22240 <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>
22241 </div>
22242 <div class="card-body">
22243 <div class="card-left">
22244 <div class="card-text">
22245 <div class="option-title">Load a saved config</div>
22246 <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>
22247 <ul class="feature-list">
22248 <li>All 15 settings restored from the file</li>
22249 <li>Fully editable — change path or output dir</li>
22250 <li>Works with any scan-config.json</li>
22251 </ul>
22252 </div>
22253 </div>
22254 <div class="card-right">
22255 <div class="file-input-wrap">
22256 <button class="btn btn-secondary" id="load-config-btn" type="button">
22257 <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>
22258 Choose config file
22259 </button>
22260 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22261 </div>
22262 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22263 </div>
22264 </div>
22265 </div>
22266 </div>
22267
22268 <!-- Option 3: Re-scan recent project -->
22269 <div class="option-card-wrap">
22270 <div class="option-card" id="recent-card">
22271 <div class="card-top-row">
22272 <div class="option-icon rescan">
22273 <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>
22274 </div>
22275 <div class="card-body">
22276 <div class="card-left">
22277 <div class="card-text">
22278 <div class="option-title">Re-scan a recent project</div>
22279 <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>
22280 <ul class="feature-list">
22281 <li>All 15+ settings restored from the saved config</li>
22282 <li>Path and output dir are editable before running</li>
22283 <li>Only scans with a saved config appear here</li>
22284 </ul>
22285 </div>
22286 </div>
22287 <div class="card-right">
22288 <div class="rescan-count-box">
22289 <div class="rescan-count-num" id="rescan-count-num">—</div>
22290 <div class="rescan-count-label">saved configs</div>
22291 </div>
22292 <a class="btn btn-secondary" href="/view-reports">
22293 <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>
22294 View all runs
22295 </a>
22296 <p class="card-tip">Opens run history</p>
22297 </div>
22298 </div>
22299 </div>
22300 <div class="section-divider"></div>
22301 <div class="recent-list" id="recent-list">
22302 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22303 </div>
22304 </div>
22305 </div>
22306
22307 </div>
22308 </div>
22309
22310 <footer class="site-footer">
22311 local code analysis - metrics, history and reports
22312 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22313 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22314 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22315 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22316 · <a href="/api-docs" rel="noopener">REST API</a>
22317 </footer>
22318
22319 <script nonce="{{ csp_nonce }}">
22320 (function () {
22321 var storageKey = 'oxide-sloc-theme';
22322 var body = document.body;
22323 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22324 var toggle = document.getElementById('theme-toggle');
22325 if (toggle) toggle.addEventListener('click', function () {
22326 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22327 body.classList.toggle('dark-theme', next === 'dark');
22328 try { localStorage.setItem(storageKey, next); } catch(e) {}
22329 });
22330
22331 (function randomizeWatermarks() {
22332 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22333 if (!wms.length) return;
22334 var placed = [];
22335 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; }
22336 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]; }
22337 var half = Math.floor(wms.length / 2);
22338 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; });
22339 })();
22340 (function spawnCodeParticles() {
22341 var container = document.getElementById('code-particles');
22342 if (!container) return;
22343 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'];
22344 var count = 38;
22345 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); }
22346 })();
22347 // Recent scans data injected from server
22348 var recentScans = {{ recent_scans_json|safe }};
22349
22350 function configToParams(cfg) {
22351 var p = new URLSearchParams();
22352 p.set('prefilled', '1');
22353 if (cfg.path) p.set('path', cfg.path);
22354 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22355 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22356 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22357 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22358 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22359 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22360 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22361 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22362 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22363 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22364 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22365 if (cfg.report_title) p.set('report_title', cfg.report_title);
22366 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22367 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22368 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22369 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22370 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22371 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22372 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22373 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22374 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22375 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22376 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22377 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22378 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22379 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22380 return p;
22381 }
22382
22383 // Build recent scan list (capped at 3 visible entries)
22384 var list = document.getElementById('recent-list');
22385 var noNote = document.getElementById('no-recent-note');
22386 var hasAny = false;
22387 var MAX_RECENT = 3;
22388 if (Array.isArray(recentScans)) {
22389 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22390 var shown = 0;
22391 validEntries.forEach(function (entry) {
22392 if (shown >= MAX_RECENT) return;
22393 shown++;
22394 hasAny = true;
22395 var item = document.createElement('div');
22396 item.className = 'recent-item';
22397 item.title = 'Restore all settings and open wizard';
22398 item.innerHTML =
22399 '<div class="recent-item-info">' +
22400 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22401 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
22402 '</div>' +
22403 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22404 item.addEventListener('click', function () {
22405 var params = configToParams(entry.config);
22406 window.location.href = '/scan?' + params.toString();
22407 });
22408 list.appendChild(item);
22409 });
22410 if (validEntries.length > MAX_RECENT) {
22411 var moreEl = document.createElement('div');
22412 moreEl.className = 'recent-more-link';
22413 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
22414 list.appendChild(moreEl);
22415 }
22416 }
22417 if (hasAny && noNote) noNote.style.display = 'none';
22418 // Update count badge
22419 var countEl = document.getElementById('rescan-count-num');
22420 if (countEl) {
22421 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22422 countEl.textContent = total > 0 ? total : '0';
22423 }
22424
22425 // Config file loader
22426 var fileInput = document.getElementById('config-file-input');
22427 var fileName = document.getElementById('config-file-name');
22428 var loadBtn = document.getElementById('load-config-btn');
22429 // Wire the visible button to open the hidden file picker.
22430 if (loadBtn && fileInput) {
22431 loadBtn.addEventListener('click', function () { fileInput.click(); });
22432 }
22433 if (fileInput) {
22434 fileInput.addEventListener('change', function () {
22435 var file = fileInput.files && fileInput.files[0];
22436 if (!file) return;
22437 if (fileName) fileName.textContent = '\u2713 ' + file.name;
22438 var reader = new FileReader();
22439 reader.onload = function (e) {
22440 try {
22441 var cfg = JSON.parse(e.target.result);
22442 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22443 var params = configToParams(cfg);
22444 window.location.href = '/scan?' + params.toString();
22445 } catch (err) {
22446 alert('Could not parse config file: ' + err.message);
22447 }
22448 };
22449 reader.readAsText(file);
22450 });
22451 }
22452
22453 function escHtml(s) {
22454 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
22455 }
22456 })();
22457 </script>
22458 <script nonce="{{ csp_nonce }}">
22459 (function(){
22460 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'}];
22461 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);});}
22462 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22463 function init(){
22464 var btn=document.getElementById('settings-btn');if(!btn)return;
22465 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22466 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>';
22467 document.body.appendChild(m);
22468 var g=document.getElementById('scheme-grid');
22469 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);});
22470 var cl=document.getElementById('settings-close');
22471 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
22472 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');});
22473 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22474 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22475 }
22476 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22477 }());
22478 </script>
22479 <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]';
22480 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;}
22481 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>
22482</body>
22483</html>
22484"##,
22485 ext = "html"
22486)]
22487struct ScanSetupTemplate {
22488 version: &'static str,
22489 recent_scans_json: String,
22490 csp_nonce: String,
22491}
22492
22493#[derive(Template)]
22494#[template(
22495 source = r##"
22496<!doctype html>
22497<html lang="en">
22498<head>
22499 <meta charset="utf-8">
22500 <meta name="viewport" content="width=device-width, initial-scale=1">
22501 <title>OxideSLOC | {{ report_title }} | Report</title>
22502 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22503 <style nonce="{{ csp_nonce }}">
22504 :root {
22505 --radius: 18px;
22506 --bg: #f5efe8;
22507 --surface: rgba(255,255,255,0.82);
22508 --surface-2: #fbf7f2;
22509 --surface-3: #efe6dc;
22510 --line: #e6d0bf;
22511 --line-strong: #dcb89f;
22512 --text: #43342d;
22513 --muted: #7b675b;
22514 --muted-2: #a08777;
22515 --nav: #b85d33;
22516 --nav-2: #7a371b;
22517 --accent: #6f9bff;
22518 --accent-2: #4a78ee;
22519 --oxide: #d37a4c;
22520 --oxide-2: #b35428;
22521 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22522 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22523 --success-bg: #e8f5ed;
22524 --success-text: #1a8f47;
22525 --info-bg: #eef3ff;
22526 --info-text: #4467d8;
22527 }
22528
22529 body.dark-theme {
22530 --bg: #1b1511;
22531 --surface: #261c17;
22532 --surface-2: #2d221d;
22533 --surface-3: #372922;
22534 --line: #524238;
22535 --line-strong: #6c5649;
22536 --text: #f5ece6;
22537 --muted: #c7b7aa;
22538 --muted-2: #aa9485;
22539 --nav: #b85d33;
22540 --nav-2: #7a371b;
22541 --accent: #6f9bff;
22542 --accent-2: #4a78ee;
22543 --oxide: #d37a4c;
22544 --oxide-2: #b35428;
22545 --shadow: 0 18px 42px rgba(0,0,0,0.28);
22546 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22547 --success-bg: #163927;
22548 --success-text: #8fe2a8;
22549 --info-bg: #1c2847;
22550 --info-text: #a9c1ff;
22551 }
22552
22553 * { box-sizing: border-box; }
22554 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); }
22555 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22556 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22557 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22558 .top-nav, .page { position: relative; z-index: 2; }
22559 .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); }
22560 .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; }
22561 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22562 .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)); }
22563 .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; }
22564 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22565 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22566 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22567 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22568 .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; }
22569 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22570 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22571 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22572 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22573 @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; } }
22574 .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; }
22575 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22576 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22577 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22578 .theme-toggle .icon-sun { display:none; }
22579 body.dark-theme .theme-toggle .icon-sun { display:block; }
22580 body.dark-theme .theme-toggle .icon-moon { display:none; }
22581 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
22582 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22583 .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);}
22584 .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;}
22585 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22586 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22587 .settings-modal-body{padding:14px 16px 16px;}
22588 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22589 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22590 .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;}
22591 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22592 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22593 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22594 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22595 .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;}
22596 .tz-select:focus{border-color:var(--oxide);}
22597 .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; }
22598 .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;}
22599 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22600 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22601 .hero, .panel { padding: 22px; }
22602 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22603 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22604 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22605 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22606 .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; }
22607 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22608 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22609 .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; }
22610 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22611 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22612 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22613 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22614 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22615 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22616 .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); }
22617 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22618 .delta-card-val { font-size:16px; font-weight:800; }
22619 .delta-card-val.pos { color:#1e7e34; }
22620 .delta-card-val.neg { color:var(--neg); }
22621 .delta-card-val.mod { color:#b35428; }
22622 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22623 .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; }
22624 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22625 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22626 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22627 .compare-ts { font-size:13px; color:var(--muted); }
22628 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22629 .compare-arrow { color: var(--muted); }
22630 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22631 .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; }
22632 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22633 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22634 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22635 .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; }
22636 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22637 .run-mgmt-card .action-buttons { justify-content:center; }
22638 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22639 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22640 .button, .copy-button {
22641 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;
22642 }
22643 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22644 @keyframes spin { to { transform: rotate(360deg); } }
22645 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22646 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22647 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22648 .path-item strong { display: block; margin-bottom: 6px; }
22649 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22650 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22651 .path-subitem { flex: 1; }
22652 .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); }
22653 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); }
22654 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22655 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22656 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22657 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22658 th { color: var(--muted); font-weight: 700; }
22659 tr:last-child td { border-bottom: none; }
22660 #subm-tbl col:nth-child(1){width:15%;}
22661 #subm-tbl col:nth-child(2){width:31%;}
22662 #subm-tbl col:nth-child(3){width:9%;}
22663 #subm-tbl col:nth-child(4){width:9%;}
22664 #subm-tbl col:nth-child(5){width:9%;}
22665 #subm-tbl col:nth-child(6){width:9%;}
22666 #subm-tbl col:nth-child(7){width:9%;}
22667 #subm-tbl col:nth-child(8){width:9%;}
22668 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22669 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22670 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22671 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22672 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22673 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22674 .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; }
22675 .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; }
22676 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22677 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22678 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22679 .muted { color: var(--muted); }
22680 /* Run-ID chip row (mirrors HTML report) */
22681 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22682 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22683 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22684 .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; }
22685 .run-id-chip[data-copy] { cursor:pointer; }
22686 a.run-id-chip { text-decoration:none; cursor:pointer; }
22687 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22688 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22689 .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; }
22690 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22691 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22692 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22693 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22694 a.commit-link-value { color:inherit; text-decoration:none; }
22695 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22696 .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; }
22697 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22698 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22699 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22700 .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; }
22701 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22702 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22703 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22704 /* Meta chips row */
22705 .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%; }
22706 .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; }
22707 .meta-chip:last-child { border-right:none; }
22708 .meta-chip b { color:var(--text); font-weight:700; }
22709 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22710 .site-footer a{color:var(--muted);}
22711 .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; }
22712 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22713 .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; }
22714 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22715 /* Stat chips (matches HTML report) */
22716 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22717 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22718 /* Hero stat strip: uniform grid where every card is the same width and the
22719 columns line up across both rows. JS sets the column count to ceil(n/2) so
22720 the cards always occupy exactly two rows; when the count is odd the last
22721 card spans two columns to fill the trailing cell with no empty gap. */
22722 .summary-strip-hero { align-items:stretch; }
22723 .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; }
22724 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22725 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22726 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22727 .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; }
22728 .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); }
22729 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22730 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22731 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22732 .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; }
22733 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22734 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22735 .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); }
22736 .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); }
22737 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22738 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22739 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22740 /* Submodule panel */
22741 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22742 /* Metrics tables stack */
22743 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22744 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22745 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22746 .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)); }
22747 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22748 /* Metrics table */
22749 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22750 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22751 .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; }
22752 .metrics-table thead th:not(:first-child) { text-align: right; }
22753 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22754 .metrics-table tbody tr:last-child td { border-bottom: none; }
22755 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22756 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22757 .metrics-table tbody tr:hover td { background: var(--surface-2); }
22758 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22759 .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; }
22760 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22761 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22762 .mt-val-pos { color: var(--pos); font-weight: 700; }
22763 .mt-val-neg { color: var(--neg); font-weight: 700; }
22764 .mt-val-zero { color: var(--muted); }
22765 .mt-val-mod { color: var(--oxide-2); }
22766 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22767 @media (max-width: 1180px) {
22768 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22769 .nav-project-slot, .nav-status { justify-content:flex-start; }
22770 .hero-top { flex-direction: column; }
22771 .run-mgmt-strip { flex-direction: column; }
22772 }
22773 .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;}
22774 @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));}}
22775 .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;}
22776 /* ── Result-page chart controls ─────────────────────────────────────────── */
22777 .r-chart-section{margin-bottom:24px;}
22778 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22779 .section-pair > .panel{flex-shrink:0;}
22780 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22781 .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;}
22782 .r-chart-select:focus{border-color:var(--accent);}
22783 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22784 .r-chart-container svg{display:block;width:100%;height:auto;}
22785 .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;}
22786 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22787 .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;}
22788 .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);}
22789 .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;}
22790 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22791 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22792 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22793 .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;}
22794 .r-chart-modal-close:hover{opacity:.7;}
22795 body.dark-theme .r-chart-modal{background:var(--surface);}
22796 .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;}
22797 .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);}
22798 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22799 .lang-bar-row:hover{transform:translateY(-2px);}
22800 .lang-bar-row .rchit:hover{filter:none;transform:none;}
22801 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22802 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22803 .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;}
22804 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22805 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22806 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22807 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22808 #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;}
22809 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22810 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22811 .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;}
22812 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22813 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22814 .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;}
22815 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22816 .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;}
22817 .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;}
22818 body.has-report-banner .top-nav{top:27px;}
22819 body.has-report-banner{padding-bottom:27px;}
22820 </style>
22821</head>
22822<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22823 <div class="background-watermarks" aria-hidden="true">
22824 <img src="/images/logo/logo-text.png" alt="" />
22825 <img src="/images/logo/logo-text.png" alt="" />
22826 <img src="/images/logo/logo-text.png" alt="" />
22827 <img src="/images/logo/logo-text.png" alt="" />
22828 <img src="/images/logo/logo-text.png" alt="" />
22829 <img src="/images/logo/logo-text.png" alt="" />
22830 <img src="/images/logo/logo-text.png" alt="" />
22831 <img src="/images/logo/logo-text.png" alt="" />
22832 <img src="/images/logo/logo-text.png" alt="" />
22833 <img src="/images/logo/logo-text.png" alt="" />
22834 <img src="/images/logo/logo-text.png" alt="" />
22835 <img src="/images/logo/logo-text.png" alt="" />
22836 <img src="/images/logo/logo-text.png" alt="" />
22837 <img src="/images/logo/logo-text.png" alt="" />
22838 </div>
22839 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22840 {% if let Some(banner) = report_header_footer %}
22841 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22842 {% endif %}
22843 <div class="top-nav">
22844 <div class="top-nav-inner">
22845 <a class="brand" href="/">
22846 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22847 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22848 </a>
22849 <div class="nav-project-slot">
22850 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22851 </div>
22852 <div class="nav-status">
22853 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22854 <div class="nav-dropdown">
22855 <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>
22856 <div class="nav-dropdown-menu">
22857 <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>
22858 </div>
22859 </div>
22860 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22861 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22862 <div class="nav-dropdown">
22863 <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>
22864 <div class="nav-dropdown-menu">
22865 <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>
22866 </div>
22867 </div>
22868 <div class="server-status-wrap" id="server-status-wrap">
22869 <div class="nav-pill server-online-pill" id="server-status-pill">
22870 <span class="status-dot" id="status-dot"></span>
22871 <span id="server-status-label">Server</span>
22872 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22873 </div>
22874 <div class="server-status-tip">
22875 OxideSLOC is running — accessible on your network.
22876 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22877 </div>
22878 </div>
22879 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22880 <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>
22881 </button>
22882 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22883 <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>
22884 <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>
22885 </button>
22886 </div>
22887 </div>
22888 </div>
22889
22890 <div class="page">
22891 <section class="hero">
22892 <div class="hero-top">
22893 <div>
22894 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22895 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22896 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22897 <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>
22898 </div>
22899 </div>
22900 <div class="hero-quick-actions">
22901 {% if server_mode %}
22902 <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>
22903 {% else %}
22904 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22905 {% endif %}
22906 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22907 {% if !server_mode %}
22908 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22909 {% endif %}
22910 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22911 <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>
22912 </div>
22913 </div>
22914
22915 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22916 <div class="run-id-row">
22917 <span class="run-id-chip" data-copy="{{ run_id }}">
22918 <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>
22919 <span class="run-id-chip-value">{{ run_id }}</span>
22920 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22921 </span>
22922 {% match git_commit_long %}
22923 {% when Some with (long_sha) %}
22924 {% match git_commit_url %}
22925 {% when Some with (commit_url) %}
22926 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22927 <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>
22928 <span class="run-id-chip-value">{{ long_sha }}</span>
22929 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22930 </a>
22931 {% when None %}
22932 <span class="run-id-chip" data-copy="{{ long_sha }}">
22933 <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>
22934 <span class="run-id-chip-value">{{ long_sha }}</span>
22935 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22936 </span>
22937 {% endmatch %}
22938 {% when None %}
22939 <span class="run-id-chip muted-chip">
22940 <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>
22941 <span class="run-id-chip-value">Not detected</span>
22942 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22943 </span>
22944 {% endmatch %}
22945 {% match git_branch %}
22946 {% when Some with (branch) %}
22947 {% match git_branch_url %}
22948 {% when Some with (branch_url) %}
22949 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22950 <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>
22951 <span class="run-id-chip-value">{{ branch }}</span>
22952 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22953 </a>
22954 {% when None %}
22955 <span class="run-id-chip">
22956 <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>
22957 <span class="run-id-chip-value">{{ branch }}</span>
22958 <span class="chip-tooltip">Git branch active at scan time</span>
22959 </span>
22960 {% endmatch %}
22961 {% when None %}
22962 <span class="run-id-chip muted-chip">
22963 <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>
22964 <span class="run-id-chip-value">Not detected</span>
22965 <span class="chip-tooltip">No Git branch was found for this scan</span>
22966 </span>
22967 {% endmatch %}
22968 {% match git_author %}
22969 {% when Some with (author) %}
22970 <span class="run-id-chip" data-author="{{ author }}">
22971 <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>
22972 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22973 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22974 </span>
22975 {% when None %}
22976 <span class="run-id-chip muted-chip">
22977 <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>
22978 <span class="run-id-chip-value">Not detected</span>
22979 <span class="chip-tooltip">No commit author was found for this scan</span>
22980 </span>
22981 {% endmatch %}
22982 </div>
22983
22984 <!-- Scan metadata row -->
22985 <div class="meta">
22986 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22987 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22988 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22989 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22990 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22991 </div>
22992
22993 <!-- All summary stat chips in one unified strip (8 columns) -->
22994 <div class="summary-strip summary-strip-hero">
22995 <div class="stat-chip" data-raw="{{ physical_lines }}">
22996 <div class="stat-chip-label">Physical lines</div>
22997 <div class="stat-chip-val">{{ physical_lines }}</div>
22998 <div class="stat-chip-exact"></div>
22999 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23000 </div>
23001 <div class="stat-chip" data-raw="{{ code_lines }}">
23002 <div class="stat-chip-label">Code</div>
23003 <div class="stat-chip-val">{{ code_lines }}</div>
23004 <div class="stat-chip-exact"></div>
23005 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23006 </div>
23007 <div class="stat-chip" data-raw="{{ comment_lines }}">
23008 <div class="stat-chip-label">Comments</div>
23009 <div class="stat-chip-val">{{ comment_lines }}</div>
23010 <div class="stat-chip-exact"></div>
23011 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23012 </div>
23013 <div class="stat-chip" data-raw="{{ blank_lines }}">
23014 <div class="stat-chip-label">Blank</div>
23015 <div class="stat-chip-val">{{ blank_lines }}</div>
23016 <div class="stat-chip-exact"></div>
23017 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23018 </div>
23019 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23020 <div class="stat-chip-label">Mixed separate</div>
23021 <div class="stat-chip-val">{{ mixed_lines }}</div>
23022 <div class="stat-chip-exact"></div>
23023 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23024 </div>
23025 <div class="stat-chip" data-raw="{{ functions }}">
23026 <div class="stat-chip-label">Functions</div>
23027 <div class="stat-chip-val">{{ functions }}</div>
23028 <div class="stat-chip-exact"></div>
23029 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23030 </div>
23031 <div class="stat-chip" data-raw="{{ classes }}">
23032 <div class="stat-chip-label">Classes / Types</div>
23033 <div class="stat-chip-val">{{ classes }}</div>
23034 <div class="stat-chip-exact"></div>
23035 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23036 </div>
23037 <div class="stat-chip" data-raw="{{ variables }}">
23038 <div class="stat-chip-label">Variables</div>
23039 <div class="stat-chip-val">{{ variables }}</div>
23040 <div class="stat-chip-exact"></div>
23041 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23042 </div>
23043 <div class="stat-chip" data-raw="{{ imports }}">
23044 <div class="stat-chip-label">Imports</div>
23045 <div class="stat-chip-val">{{ imports }}</div>
23046 <div class="stat-chip-exact"></div>
23047 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23048 </div>
23049 <div class="stat-chip" data-raw="{{ test_count }}">
23050 <div class="stat-chip-label">Tests</div>
23051 <div class="stat-chip-val">{{ test_count }}</div>
23052 <div class="stat-chip-exact"></div>
23053 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23054 </div>
23055 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23056 <div class="stat-chip-label">Code density</div>
23057 <div class="stat-chip-val stat-chip-density-val">—</div>
23058 <div class="stat-chip-exact"></div>
23059 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23060 </div>
23061 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23062 <div class="stat-chip-label">Files analyzed</div>
23063 <div class="stat-chip-val">{{ files_analyzed }}</div>
23064 <div class="stat-chip-exact"></div>
23065 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23066 </div>
23067 {% if cyclomatic_complexity > 0 %}
23068 <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 %}>
23069 <div class="stat-chip-label">Complexity score</div>
23070 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23071 <div class="stat-chip-exact"></div>
23072 <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>
23073 </div>
23074 {% endif %}
23075 {% if let Some(ls) = lsloc %}
23076 <div class="stat-chip" data-raw="{{ ls }}">
23077 <div class="stat-chip-label">Logical SLOC</div>
23078 <div class="stat-chip-val">{{ ls }}</div>
23079 <div class="stat-chip-exact"></div>
23080 <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>
23081 </div>
23082 {% endif %}
23083 {% if uloc > 0 %}
23084 <div class="stat-chip" data-raw="{{ uloc }}">
23085 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23086 <div class="stat-chip-val">{{ uloc }}</div>
23087 <div class="stat-chip-exact"></div>
23088 <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>
23089 </div>
23090 {% endif %}
23091 {% if uloc > 0 && dryness_pct_str != "" %}
23092 <div class="stat-chip">
23093 <div class="stat-chip-label">DRYness</div>
23094 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23095 <div class="stat-chip-exact"></div>
23096 <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>
23097 </div>
23098 {% endif %}
23099 {% if duplicate_group_count > 0 %}
23100 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23101 <div class="stat-chip-label">Duplicate groups</div>
23102 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23103 <div class="stat-chip-exact"></div>
23104 <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>
23105 </div>
23106 {% endif %}
23107 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23108 odd, so the strip always forms exactly two full rows with every column
23109 aligned and every card the same width (no oversized card, no gap). -->
23110 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23111 <div class="stat-chip-label">Assertions</div>
23112 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23113 <div class="stat-chip-exact"></div>
23114 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23115 </div>
23116 </div>
23117
23118 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23119 <div class="compare-banner">
23120 <div class="compare-banner-body">
23121 <div class="compare-banner-top">
23122 <div class="compare-banner-meta">
23123 <span class="compare-label">Previous scan</span>
23124 <span class="compare-ts">{{ prev_ts }}</span>
23125 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23126 {% if let Some(prev_code) = prev_run_code_lines %}
23127 <div class="compare-banner-stats" style="margin-top:4px;">
23128 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23129 <span class="compare-arrow">→</span>
23130 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23131 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23132 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23133 </div>
23134 {% endif %}
23135 </div>
23136 {% if delta_lines_added.is_some() %}
23137 <div class="delta-cards-inline">
23138 <div class="delta-card-inline">
23139 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23140 <div class="delta-card-lbl">lines added</div>
23141 <div class="delta-card-tip">Code lines added since the previous scan</div>
23142 </div>
23143 <div class="delta-card-inline">
23144 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23145 <div class="delta-card-lbl">lines removed</div>
23146 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23147 </div>
23148 <div class="delta-card-inline">
23149 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23150 <div class="delta-card-lbl">unmodified lines</div>
23151 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23152 </div>
23153 <div class="delta-card-inline">
23154 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23155 <div class="delta-card-lbl">files modified</div>
23156 <div class="delta-card-tip">Files with at least one line changed</div>
23157 </div>
23158 <div class="delta-card-inline">
23159 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23160 <div class="delta-card-lbl">files added</div>
23161 <div class="delta-card-tip">New files added since the previous scan</div>
23162 </div>
23163 <div class="delta-card-inline">
23164 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23165 <div class="delta-card-lbl">files removed</div>
23166 <div class="delta-card-tip">Files deleted since the previous scan</div>
23167 </div>
23168 <div class="delta-card-inline">
23169 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23170 <div class="delta-card-lbl">files unchanged</div>
23171 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23172 </div>
23173 </div>
23174 {% else %}
23175 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23176 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23177 </p>
23178 {% endif %}
23179 </div>
23180 <div class="compare-banner-actions">
23181 <div class="compare-banner-actions-left">
23182 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23183 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23184 </div>
23185 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23186 </div>
23187 </div>
23188 </div>
23189 {% endif %}{% endif %}
23190
23191 <div class="action-grid">
23192 <div class="action-card">
23193 <h3>HTML report</h3>
23194 <div class="action-buttons">
23195 {% match html_url %}
23196 {% when Some with (url) %}
23197 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23198 {% when None %}{% endmatch %}
23199 {% match html_download_url %}
23200 {% when Some with (url) %}
23201 <a class="button secondary" href="{{ url }}">Download HTML</a>
23202 {% when None %}{% endmatch %}
23203 {% match html_path %}
23204 {% when Some with (_path) %}{% when None %}{% endmatch %}
23205 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23206 </div>
23207 </div>
23208 <div class="action-card">
23209 <h3>PDF report</h3>
23210 <div class="action-buttons">
23211 {% match pdf_url %}
23212 {% when Some with (url) %}
23213 {% if pdf_generating %}
23214 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23215 <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>
23216 Generating PDF…
23217 </button>
23218 {% else %}
23219 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23220 {% endif %}
23221 {% when None %}
23222 {% match html_url %}
23223 {% when Some with (_hurl) %}
23224 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23225 <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>
23226 {% when None %}
23227 <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;">
23228 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23229 </p>
23230 {% endmatch %}
23231 {% endmatch %}
23232 {% match pdf_download_url %}
23233 {% when Some with (url) %}
23234 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23235 {% when None %}{% endmatch %}
23236 {% match pdf_url %}
23237 {% when Some with (_) %}
23238 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23239 {% when None %}{% endmatch %}
23240 </div>
23241 </div>
23242 <div class="action-card">
23243 <h3>JSON result</h3>
23244 <div class="action-buttons">
23245 {% match json_url %}
23246 {% when Some with (url) %}
23247 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23248 {% when None %}{% endmatch %}
23249 {% match json_download_url %}
23250 {% when Some with (url) %}
23251 <a class="button secondary" href="{{ url }}">Download JSON</a>
23252 {% when None %}{% endmatch %}
23253 {% match json_path %}
23254 {% when Some with (_path) %}
23255 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23256 {% when None %}
23257 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23258 {% endmatch %}
23259 </div>
23260 </div>
23261 <div class="action-card">
23262 <h3>Scan config</h3>
23263 <div class="action-buttons">
23264 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23265 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23266 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23267 </div>
23268 </div>
23269 {% if confluence_configured %}
23270 <div class="action-card" id="confluenceCard">
23271 <h3>Confluence</h3>
23272 <div class="action-buttons">
23273 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23274 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23275 </div>
23276 <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>
23277 </div>
23278 {% endif %}
23279 </div>
23280 {% if confluence_configured %}
23281 <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;">
23282 <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);">
23283 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23284 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23285 <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;">
23286 <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>
23287 <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;">
23288 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23289 <div style="display:flex;gap:10px;justify-content:flex-end;">
23290 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23291 <button class="button" id="confSubmitBtn" type="button">Post</button>
23292 </div>
23293 </div>
23294 </div>
23295 {% endif %}
23296 <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;">
23297 <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);">
23298 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23299 <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>
23300 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23301 <div style="display:flex;gap:18px;justify-content:flex-end;">
23302 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23303 <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>
23304 </div>
23305 </div>
23306 </div>
23307 {% if !submodule_rows.is_empty() %}
23308 <div class="submodule-panel">
23309 <div class="toolbar-row">
23310 <div>
23311 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23312 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23313 </div>
23314 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23315 </div>
23316 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23317 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23318 <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>
23319 <thead>
23320 <tr>
23321 <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>
23322 <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>
23323 <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>
23324 <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>
23325 <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>
23326 <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>
23327 <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>
23328 <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>
23329 </tr>
23330 </thead>
23331 <tbody>
23332 {% for row in submodule_rows %}
23333 <tr>
23334 <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>
23335 <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>
23336 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23337 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23338 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23339 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23340 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23341 <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>
23342 </tr>
23343 {% endfor %}
23344 </tbody>
23345 </table>
23346 </div>
23347 </div>
23348 {% endif %}
23349
23350 <div class="metrics-tables-stack">
23351
23352 <div class="metrics-table-wrap">
23353 <div class="metrics-table-title">Files</div>
23354 <table class="metrics-table">
23355 <thead>
23356 <tr>
23357 <th>Metric</th>
23358 <th>This Run</th>
23359 <th>Previous</th>
23360 <th>Change</th>
23361 </tr>
23362 </thead>
23363 <tbody>
23364 <tr>
23365 <td>Files analyzed</td>
23366 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23367 <td>{{ prev_fa_str|commas }}</td>
23368 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23369 </tr>
23370 <tr>
23371 <td>Files skipped</td>
23372 <td>{{ files_skipped|commas }}</td>
23373 <td>{{ prev_fs_str|commas }}</td>
23374 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23375 </tr>
23376 <tr>
23377 <td>Files modified</td>
23378 <td class="mt-val-na">—</td>
23379 <td class="mt-val-na">—</td>
23380 <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>
23381 </tr>
23382 <tr>
23383 <td>Files unchanged</td>
23384 <td class="mt-val-na">—</td>
23385 <td class="mt-val-na">—</td>
23386 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23387 </tr>
23388 </tbody>
23389 </table>
23390 </div>
23391
23392 <div class="metrics-table-wrap">
23393 <div class="metrics-table-title">Line Counts</div>
23394 <table class="metrics-table">
23395 <thead>
23396 <tr>
23397 <th>Metric</th>
23398 <th>This Run</th>
23399 <th>Previous</th>
23400 <th>Change</th>
23401 </tr>
23402 </thead>
23403 <tbody>
23404 <tr>
23405 <td>Physical lines</td>
23406 <td class="mt-val-large">{{ physical_lines|commas }}</td>
23407 <td>{{ prev_pl_str|commas }}</td>
23408 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23409 </tr>
23410 <tr>
23411 <td>Code lines</td>
23412 <td class="mt-val-large">{{ code_lines|commas }}</td>
23413 <td>{{ prev_cl_str|commas }}</td>
23414 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23415 </tr>
23416 <tr>
23417 <td>Comment lines</td>
23418 <td>{{ comment_lines|commas }}</td>
23419 <td>{{ prev_cml_str|commas }}</td>
23420 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23421 </tr>
23422 <tr>
23423 <td>Blank lines</td>
23424 <td>{{ blank_lines|commas }}</td>
23425 <td>{{ prev_bl_str|commas }}</td>
23426 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23427 </tr>
23428 <tr>
23429 <td>Mixed (separate)</td>
23430 <td>{{ mixed_lines|commas }}</td>
23431 <td class="mt-val-na">—</td>
23432 <td class="mt-val-na">—</td>
23433 </tr>
23434 </tbody>
23435 </table>
23436 </div>
23437
23438 <div class="metrics-tables-lower">
23439 <div class="metrics-table-wrap">
23440 <div class="metrics-table-title">Code Structure</div>
23441 <table class="metrics-table">
23442 <thead>
23443 <tr>
23444 <th>Metric</th>
23445 <th>This Run</th>
23446 </tr>
23447 </thead>
23448 <tbody>
23449 <tr>
23450 <td>Functions</td>
23451 <td>{{ functions|commas }}</td>
23452 </tr>
23453 <tr>
23454 <td>Classes / Types</td>
23455 <td>{{ classes|commas }}</td>
23456 </tr>
23457 <tr>
23458 <td>Variables</td>
23459 <td>{{ variables|commas }}</td>
23460 </tr>
23461 <tr>
23462 <td>Imports</td>
23463 <td>{{ imports|commas }}</td>
23464 </tr>
23465 </tbody>
23466 </table>
23467 </div>
23468
23469 <div class="metrics-table-wrap">
23470 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23471 <table class="metrics-table">
23472 <thead>
23473 <tr>
23474 <th>Metric</th>
23475 <th>Change</th>
23476 </tr>
23477 </thead>
23478 <tbody>
23479 <tr>
23480 <td>Lines added</td>
23481 <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>
23482 </tr>
23483 <tr>
23484 <td>Lines removed</td>
23485 <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>
23486 </tr>
23487 <tr>
23488 <td>Lines modified (net)</td>
23489 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23490 </tr>
23491 <tr>
23492 <td>Lines unmodified</td>
23493 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23494 </tr>
23495 </tbody>
23496 </table>
23497 </div>
23498 </div>
23499
23500 </div>
23501
23502 <div class="path-list">
23503 <div class="path-item">
23504 <div class="path-item-label">Project path</div>
23505 {% 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 %}
23506 </div>
23507 <div class="path-item">
23508 <div class="path-item-label">Git branch</div>
23509 {% if let Some(branch) = git_branch %}
23510 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23511 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23512 {% else %}
23513 <code style="color:var(--muted)">—</code>
23514 {% endif %}
23515 </div>
23516 <div class="path-item">
23517 <div class="path-item-label">Output folder</div>
23518 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23519 </div>
23520 <div class="path-item">
23521 <div class="path-item-label">Run ID</div>
23522 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23523 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23524 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23525 </div>
23526 </div>
23527 </div>
23528 </section>
23529
23530 {% if has_cocomo %}
23531 <div class="cocomo-box" style="margin-top:24px;">
23532 <div class="cocomo-box-head">
23533 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
23534 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23535 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23536 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23537 </span>
23538 </div>
23539 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23540 <div class="stat-chip">
23541 <div class="stat-chip-label">Person-months</div>
23542 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23543 <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>
23544 </div>
23545 <div class="stat-chip">
23546 <div class="stat-chip-label">Schedule (months)</div>
23547 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23548 <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>
23549 </div>
23550 <div class="stat-chip">
23551 <div class="stat-chip-label">Avg. Team Size</div>
23552 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23553 <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>
23554 </div>
23555 <div class="stat-chip">
23556 <div class="stat-chip-label">Input KSLOC</div>
23557 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23558 <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>
23559 </div>
23560 </div>
23561 <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>
23562 </div>
23563 {% endif %}
23564
23565 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23566 <div class="cocomo-box" style="margin-top:24px;">
23567 <div class="cocomo-box-head">
23568 <span class="cocomo-box-title">Tests & Coverage</span>
23569 {% if has_coverage_data %}
23570 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23571 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23572 </span>
23573 {% endif %}
23574 </div>
23575 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23576 <div class="stat-chip">
23577 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23578 <div class="stat-chip-label">Test Functions</div>
23579 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23580 </div>
23581 <div class="stat-chip">
23582 {% if has_coverage_data %}
23583 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23584 {% else %}
23585 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23586 {% endif %}
23587 <div class="stat-chip-label">Line Coverage</div>
23588 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23589 </div>
23590 <div class="stat-chip">
23591 {% if !cov_fn_pct.is_empty() %}
23592 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23593 {% else %}
23594 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23595 {% endif %}
23596 <div class="stat-chip-label">Fn Coverage</div>
23597 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23598 </div>
23599 <div class="stat-chip">
23600 {% if !cov_branch_pct.is_empty() %}
23601 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23602 {% else %}
23603 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23604 {% endif %}
23605 <div class="stat-chip-label">Branch Coverage</div>
23606 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23607 </div>
23608 </div>
23609 {% if has_coverage_data %}
23610 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
23611 {% else %}
23612 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
23613 {% endif %}
23614 </div>
23615
23616 <div class="section-pair">
23617 <section class="panel">
23618 <div class="toolbar-row">
23619 <div>
23620 <h2>Language Breakdown</h2>
23621 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23622 </div>
23623 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23624 </div>
23625 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23626 </section>
23627
23628 <section class="panel r-chart-section">
23629 <div class="toolbar-row" style="margin-bottom:16px;">
23630 <div>
23631 <h2>Visualizations</h2>
23632 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23633 </div>
23634 </div>
23635
23636 <div class="r-viz-grid">
23637 <div class="r-viz-card">
23638 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23639 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23640 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23641 </div>
23642 <div class="r-chart-tab-bar">
23643 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23644 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23645 </div>
23646 <div class="r-chart-container" id="r-composition-chart"></div>
23647 </div>
23648 <div class="r-viz-card">
23649 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23650 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23651 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23652 </div>
23653 <div class="r-chart-container" id="r-scatter-chart"></div>
23654 </div>
23655 {% if has_semantic_data %}
23656 <div class="r-viz-card">
23657 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23658 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23659 <select class="r-chart-select" id="r-semantic-metric">
23660 <option value="functions">Functions</option>
23661 <option value="classes">Classes</option>
23662 <option value="variables">Variables</option>
23663 <option value="imports">Imports</option>
23664 <option value="tests">Tests</option>
23665 </select>
23666 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23667 </div>
23668 <div class="r-chart-container" id="r-semantic-chart"></div>
23669 </div>
23670 {% endif %}
23671 <div class="r-viz-card">
23672 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23673 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23674 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23675 </div>
23676 <div class="r-chart-container" id="r-density-chart"></div>
23677 </div>
23678 <div class="r-viz-card">
23679 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23680 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23681 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23682 </div>
23683 <div class="r-chart-container" id="r-avglines-chart"></div>
23684 </div>
23685 <div class="r-viz-card">
23686 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23687 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23688 <select class="r-chart-select" id="r-sub-metric">
23689 <option value="code">Code Lines</option>
23690 <option value="comment">Comments</option>
23691 <option value="blank">Blank Lines</option>
23692 <option value="physical">Physical Lines</option>
23693 <option value="files">Files</option>
23694 </select>
23695 <select class="r-chart-select" id="r-sub-sort">
23696 <option value="desc">Value ↓</option>
23697 <option value="asc">Value ↑</option>
23698 <option value="name">Name A→Z</option>
23699 </select>
23700 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23701 </div>
23702 <div class="r-chart-container" id="r-submodule-chart"></div>
23703 </div>
23704 </div>
23705
23706 </section>
23707 </div>
23708
23709 </div>
23710
23711 <div id="r-tt" aria-hidden="true"></div>
23712
23713 <script nonce="{{ csp_nonce }}">
23714 (function () {
23715 var body = document.body;
23716 var themeToggle = document.getElementById('theme-toggle');
23717 var storageKey = 'oxide-sloc-theme';
23718
23719 function applyTheme(theme) {
23720 body.classList.toggle('dark-theme', theme === 'dark');
23721 }
23722
23723 function loadSavedTheme() {
23724 try {
23725 var saved = localStorage.getItem(storageKey);
23726 if (saved === 'dark' || saved === 'light') {
23727 applyTheme(saved);
23728 }
23729 } catch (e) {}
23730 }
23731
23732 if (themeToggle) {
23733 themeToggle.addEventListener('click', function () {
23734 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23735 applyTheme(nextTheme);
23736 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23737 });
23738 }
23739
23740 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23741 button.addEventListener('click', function () {
23742 var value = button.getAttribute('data-copy-value') || '';
23743 if (!value) return;
23744 var originalText = button.textContent;
23745 function flashSuccess() {
23746 button.textContent = 'Copied!';
23747 setTimeout(function () { button.textContent = originalText; }, 1800);
23748 }
23749 function flashFail() {
23750 button.textContent = 'Copy failed';
23751 setTimeout(function () { button.textContent = originalText; }, 2000);
23752 }
23753 if (navigator.clipboard && navigator.clipboard.writeText) {
23754 navigator.clipboard.writeText(value).then(flashSuccess, function () {
23755 fallbackCopy(value, flashSuccess, flashFail);
23756 });
23757 } else {
23758 fallbackCopy(value, flashSuccess, flashFail);
23759 }
23760 });
23761 });
23762 function fallbackCopy(text, onSuccess, onFail) {
23763 try {
23764 var ta = document.createElement('textarea');
23765 ta.value = text;
23766 ta.style.position = 'fixed';
23767 ta.style.top = '-9999px';
23768 ta.style.left = '-9999px';
23769 document.body.appendChild(ta);
23770 ta.focus();
23771 ta.select();
23772 var ok = document.execCommand('copy');
23773 document.body.removeChild(ta);
23774 if (ok) { onSuccess(); } else { onFail(); }
23775 } catch (e) { onFail(); }
23776 }
23777
23778 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23779 btn.addEventListener('click', function () {
23780 var folder = btn.getAttribute('data-folder') || '';
23781 if (!folder) return;
23782 var orig = btn.textContent;
23783 fetch('/open-path?path=' + encodeURIComponent(folder))
23784 .then(function (r) { return r.json(); })
23785 .then(function (d) {
23786 if (d && d.server_mode_disabled) {
23787 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23788 } else if (d && d.ok) {
23789 btn.textContent = 'Opened!';
23790 setTimeout(function () { btn.textContent = orig; }, 1800);
23791 }
23792 })
23793 .catch(function () {
23794 btn.textContent = 'Failed';
23795 setTimeout(function () { btn.textContent = orig; }, 2000);
23796 });
23797 });
23798 });
23799
23800 loadSavedTheme();
23801
23802 // ── Compact number formatting for stat chips ──────────────────────────
23803 (function(){
23804 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();}
23805 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23806 var raw=parseInt(chip.getAttribute('data-raw'),10);
23807 if(isNaN(raw))return;
23808 var valEl=chip.querySelector('.stat-chip-val');
23809 if(valEl)valEl.textContent=fmt(raw);
23810 var exactEl=chip.querySelector('.stat-chip-exact');
23811 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23812 });
23813 // Code density chip
23814 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23815 var code=parseInt(chip.getAttribute('data-code'),10);
23816 var phys=parseInt(chip.getAttribute('data-physical'),10);
23817 if(isNaN(code)||isNaN(phys)||phys===0)return;
23818 var pct=(code/phys*100).toFixed(1)+'%';
23819 var valEl=chip.querySelector('.stat-chip-val');
23820 if(valEl)valEl.textContent=pct;
23821 });
23822 // Populate author handle from data-author attribute
23823 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23824 var author=chip.getAttribute('data-author');
23825 var el=chip.querySelector('.author-handle');
23826 if(el)el.textContent='/'+author.replace(/\s+/g,'');
23827 });
23828 // Click-to-copy on run-id-chip elements
23829 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23830 chip.addEventListener('click',function(){
23831 var val=chip.getAttribute('data-copy');
23832 if(!val)return;
23833 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23834 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);}
23835 chip.classList.add('chip-copied-flash');
23836 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23837 });
23838 });
23839 // Format delta card values with data-raw using comma-separated full numbers
23840 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23841 var raw=parseInt(card.getAttribute('data-raw'),10);
23842 if(isNaN(raw))return;
23843 var valEl=card.querySelector('.delta-card-val');
23844 if(valEl)valEl.textContent=raw.toLocaleString();
23845 });
23846 // Format code-before / code-now numbers in the compare banner stats line
23847 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23848 var raw=parseInt(el.getAttribute('data-raw'),10);
23849 if(!isNaN(raw))el.textContent=raw.toLocaleString();
23850 });
23851 })();
23852
23853 // ── Shared tooltip for all result-page charts ─────────────────────────
23854 var rTT=(function(){
23855 var el=document.getElementById('r-tt');
23856 if(!el)return{s:function(){},h:function(){},m:function(){}};
23857 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23858 function hide(){el.style.display='none';}
23859 function move(e){
23860 var x=e.clientX+16,y=e.clientY-12;
23861 var r=el.getBoundingClientRect();
23862 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23863 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23864 el.style.left=x+'px';el.style.top=y+'px';
23865 }
23866 return{s:show,h:hide,m:move};
23867 })();
23868 window.rTT=rTT;
23869
23870 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23871 (function(){
23872 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23873 document.addEventListener('mouseover',function(e){
23874 var t=e.target;
23875 while(t&&t.getAttribute){
23876 var l=t.getAttribute('data-ttl');
23877 if(l!==null){
23878 var v=t.getAttribute('data-ttv')||'';
23879 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23880 return;
23881 }
23882 t=t.parentNode;
23883 }
23884 });
23885 document.addEventListener('mouseout',function(e){
23886 var t=e.target;
23887 while(t&&t.getAttribute){
23888 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23889 t=t.parentNode;
23890 }
23891 });
23892 document.addEventListener('mousemove',function(e){
23893 var el=document.getElementById('r-tt');
23894 if(el&&el.style.display!=='none')rTT.m(e);
23895 });
23896 window.addEventListener('blur',function(){rTT.h();});
23897 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23898 })();
23899
23900 // ── Language overview charts ───────────────────────────────────────────
23901 (function(){
23902 var D={{ lang_chart_json|safe }};
23903 if(!D||!D.length)return;
23904 var el=document.getElementById('result-lang-charts');
23905 if(!el)return;
23906 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23907 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23908 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23909 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();}
23910 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23911 function px(n){return Math.round(n);}
23912 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+'"';}
23913 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23914 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23915 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23916 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;}
23917 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23918
23919 // Donut chart — height matches the stacked-bar chart so both panels align
23920 var rHb_d=28;
23921 var DH=Math.max(220,D.length*rHb_d+32);
23922 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23923 var legX=208,DW=395;
23924 var legCount=D.length;
23925 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23926 var legYStart=Math.round((DH-legCount*legSpacing)/2);
23927 var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
23928 if(D.length===1){
23929 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23930 ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
23931 } else {
23932 var smalls=[];
23933 var ang=-Math.PI/2;
23934 D.forEach(function(d,i){
23935 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23936 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23937 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23938 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23939 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23940 var pct=Math.round(d.code/tot*100);
23941 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"/>';
23942 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text 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]});}
23943 ang+=sw;
23944 });
23945 // Small slices (<5%) get outside labels positioned near each slice's own
23946 // angular position (a slice on the left gets its label/leader on the left),
23947 // then nudged apart horizontally so text never overlaps. Leader lines point
23948 // from each slice to its label. Horizontal text keeps long names legible;
23949 // the whole SVG scales up in Full View so these stay readable there too.
23950 if(smalls.length){
23951 smalls.sort(function(a,b){return a.mAng-b.mAng;});
23952 var sPad=6,sRowY=11;
23953 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)));});
23954 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;}
23955 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23956 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23957 smalls.forEach(function(sm){
23958 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23959 ds+='<line 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;"/>';
23960 ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
23961 });
23962 }
23963 }
23964 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23965 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23966 D.forEach(function(d,i){
23967 var ly=legYStart+i*legSpacing;
23968 var pctL=Math.round(d.code/tot*100);
23969 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
23970 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
23971 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23972 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23973 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23974 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23975 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>';
23976 ds+='</g>';
23977 });
23978 ds+='</svg>';
23979
23980 // Horizontal stacked-bar chart — fills container width
23981 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23982 var LW=108,BW=260,svgW=LW+BW+68;
23983 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23984 var barBH=Math.min(32,Math.round(barRhb*0.7));
23985 var SH=DH;
23986 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23987 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">';
23988 D.forEach(function(d,i){
23989 var y=barTopPad+i*barRhb,x=LW;
23990 var phys=d.physical||d.code+d.comments+d.blanks;
23991 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23992 var lmid=y+barBH/2+4;
23993 // Combined breakdown shown when hovering the row, the language name, or the
23994 // total at the bar end (\n becomes a line break in the tooltip).
23995 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23996 bs+='<g class="lang-bar-row">';
23997 // Hit area ends just past the total label so empty space to the right of the
23998 // bar does not trigger the tooltip — only the name, bar and total are hot.
23999 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24000 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24001 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>';
24002 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;}
24003 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;}
24004 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>';}
24005 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>';
24006 bs+='</g>';
24007 });
24008 var ly=SH-14;
24009 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24010 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24011 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24012 var totAll=totC+totCm+totBl||1;
24013 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24014 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24015 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24016 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24017 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24018 bs+='<g data-kind="code" style="cursor:pointer;">'
24019 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24020 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24021 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24022 +'</g>';
24023 bs+='<g data-kind="comment" style="cursor:pointer;">'
24024 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24025 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24026 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24027 +'</g>';
24028 bs+='<g data-kind="blank" style="cursor:pointer;">'
24029 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24030 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24031 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24032 +'</g>';
24033 bs+='</svg>';
24034 el.innerHTML='<div class="r-lang-overview">'+
24035 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24036 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24037 '</div>';
24038 function wireDonutLegend(svg){
24039 if(!svg)return;
24040 var paths=svg.querySelectorAll('path[data-lang]');
24041 function hl(lang){for(var i=0;i<paths.length;i++){if(paths[i].getAttribute('data-lang')===lang){paths[i].style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.25))';paths[i].style.transform='scale(1.05)';paths[i].style.opacity='1';}else{paths[i].style.opacity='0.32';paths[i].style.filter='none';paths[i].style.transform='none';}}}
24042 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
24043 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();});
24044 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();});
24045 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24046 }
24047 function wireMixLegend(svg){
24048 if(!svg)return;
24049 var legGs=svg.querySelectorAll('g[data-kind]');
24050 var allRects=svg.querySelectorAll('rect[data-kind]');
24051 if(!legGs.length)return;
24052 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';}}
24053 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='';}}
24054 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]);}
24055 }
24056 wireDonutLegend(el.querySelector('svg'));
24057 wireMixLegend(el.querySelectorAll('svg')[1]);
24058
24059 // ── Language breakdown Full View expand ─────────────────────────────────
24060 var langOvBtn=document.getElementById('result-lang-overview-expand');
24061 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24062 var src=document.getElementById('result-lang-charts');
24063 if(!src)return;
24064 var overlay=document.createElement('div');
24065 overlay.className='r-chart-modal-overlay';
24066 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>';
24067 document.body.appendChild(overlay);
24068 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24069 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24070 var wrap=document.getElementById('result-lang-overview-modal-wrap');
24071 if(wrap){
24072 wrap.innerHTML=src.innerHTML;
24073 var svgs=wrap.querySelectorAll('svg');
24074 for(var i=0;i<svgs.length;i++){
24075 svgs[i].removeAttribute('width');
24076 svgs[i].removeAttribute('height');
24077 svgs[i].style.cssText='display:block;width:100%;height:auto;';
24078 }
24079 var ov=wrap.querySelector('.r-lang-overview');
24080 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24081 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24082 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24083 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24084 wireDonutLegend(wrap.querySelector('svg'));
24085 wireMixLegend(wrap.querySelectorAll('svg')[1]);
24086 requestAnimationFrame(function(){
24087 var ss=wrap.querySelectorAll('svg');
24088 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%;';}}
24089 });
24090 }
24091 });}
24092 })();
24093
24094 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24095 (function(){
24096 var LANG_D={{ lang_chart_json|safe }};
24097 var SCAT_D={{ scatter_chart_json|safe }};
24098 var SEM_D={{ semantic_chart_json|safe }};
24099 var SUB_D={{ submodule_chart_json|safe }};
24100 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24101 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24102 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();}
24103 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24104 function px(n){return Math.round(n);}
24105 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+'"';}
24106 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24107 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24108 // rather than disappear; the SVG scales up in Full View).
24109 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;}
24110
24111 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24112 function renderCompositionInEl(el,mode,shOvr){
24113 if(!el||!LANG_D||!LANG_D.length)return;
24114 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24115 var LW=110,SH=shOvr||300;
24116 var svgW=Math.max(320,el.offsetWidth||480);
24117 var BW=Math.max(120,svgW-LW-80);
24118 var legendH=24,topPad=4;
24119 var n=LANG_D.length||1;
24120 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24121 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24122 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">';
24123 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24124 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24125 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24126 var totAll2=totC2+totCm2+totBl2||1;
24127 if(mode==='pct'){
24128 LANG_D.forEach(function(d,i){
24129 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24130 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24131 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24132 var lmid=y+Math.floor(bH/2)+4;
24133 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24134 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>';
24135 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;}
24136 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;}
24137 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>';}
24138 var pct=Math.round((d.code||0)/tot2*100);
24139 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>';
24140 });
24141 } else {
24142 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24143 LANG_D.forEach(function(d,i){
24144 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24145 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24146 var lmid=y+Math.floor(bH/2)+4;
24147 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));
24148 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>';
24149 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;}
24150 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;}
24151 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>';}
24152 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>';
24153 });
24154 }
24155 var ly=SH-legendH+4;
24156 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24157 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24158 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24159 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24160 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24161 s+='<g data-kind="code" style="cursor:pointer;">'
24162 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24163 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24164 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24165 +'</g>';
24166 s+='<g data-kind="comment" style="cursor:pointer;">'
24167 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24168 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24169 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24170 +'</g>';
24171 s+='<g data-kind="blank" style="cursor:pointer;">'
24172 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24173 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24174 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24175 +'</g>';
24176 s+='</svg>';
24177 el.innerHTML=s;
24178 wireMixLegendEl(el);
24179 }
24180 function wireMixLegendEl(container){
24181 var svg=container&&container.querySelector('svg');
24182 if(!svg)return;
24183 var legGs=svg.querySelectorAll('g[data-kind]');
24184 var allRects=svg.querySelectorAll('rect[data-kind]');
24185 if(!legGs.length)return;
24186 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';}}
24187 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='';}}
24188 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]);}
24189 }
24190 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24191 renderComposition('abs');
24192 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24193 btn.addEventListener('click',function(){
24194 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24195 btn.classList.add('active');
24196 renderComposition(btn.getAttribute('data-rcomp'));
24197 });
24198 });
24199
24200 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24201 function wireScatterLegend(container){
24202 var svg=container&&container.querySelector('svg');
24203 if(!svg)return;
24204 var legGs=svg.querySelectorAll('g[data-lang]');
24205 var circs=svg.querySelectorAll('circle[data-lang]');
24206 if(!legGs.length)return;
24207 function hl(lang){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))';}else{c.style.opacity='0.12';c.style.filter='none';}}
24208 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24209 function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24210 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]);}
24211 }
24212 function renderScatterInEl(el,hOvr){
24213 if(!el||!SCAT_D||!SCAT_D.length)return;
24214 var n=SCAT_D.length;
24215 var H=hOvr||300,PL=52,PB=36,PT=44;
24216 var W=Math.max(320,el.offsetWidth||480);
24217 var cH=H-PT-PB;
24218 // Legend: max 2 columns, fills vertical space. The compact card shows the
24219 // top languages by code lines plus a "+N more" row linking to Full View;
24220 // Full View (hOvr set) shows every language across up to 2 tall columns.
24221 var compact=!hOvr;
24222 var availH=Math.max(120,H-24);
24223 var rowsFit=Math.max(2,Math.floor(availH/18));
24224 var legTrunc=compact&&(n>2*rowsFit);
24225 var legShown=legTrunc?(2*rowsFit-1):n;
24226 var legTotal=legTrunc?(2*rowsFit):n;
24227 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24228 var legPerCol=Math.ceil(legTotal/legCols);
24229 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24230 var legColW=hOvr?144:130;
24231 var LG=26;
24232 var legW=legCols*legColW;
24233 var cW=W-PL-LG-legW;
24234 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24235 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24236 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24237 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24238 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24239 var logMaxF=Math.log1p(maxF);
24240 var s='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24241 // Y grid lines (linear)
24242 [0,0.25,0.5,0.75,1].forEach(function(t){
24243 var y=PT+cH*(1-t);
24244 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24245 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>';
24246 });
24247 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24248 [0,0.25,0.5,0.75,1].forEach(function(t){
24249 var x=PL+cW*t;
24250 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24251 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24252 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>';
24253 });
24254 // Full View (hOvr set) has the vertical room to show the per-bubble value
24255 // line; the compact card shows only the language label to avoid the
24256 // overlapping-label clutter seen when bubbles cluster together.
24257 var showVal=!!hOvr;
24258 SCAT_D.forEach(function(d,i){
24259 // X uses log1p so outlier languages (many files) don't push others to the far left
24260 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24261 var cy2=PT+cH-d.code/maxC*cH;
24262 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24263 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"/>';
24264 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24265 if(showVal){
24266 var ty2=Math.max(24,px(cy2)-px(r)-3);
24267 var ty1=Math.max(12,ty2-14);
24268 s+='<text 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>';
24269 s+='<text 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>';
24270 }else{
24271 var ly2=Math.max(12,px(cy2)-px(r)-3);
24272 s+='<text 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>';
24273 }
24274 });
24275 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>';
24276 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>';
24277 // Legend (right side — top languages, max 2 columns, fills height)
24278 var legX=PL+cW+LG;
24279 var legBlockH=legPerCol*legRowH;
24280 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24281 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24282 for(var lk=0;lk<legShown;lk++){
24283 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24284 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24285 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;">';
24286 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24287 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24288 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>';
24289 s+='</g>';
24290 }
24291 if(legTrunc){
24292 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24293 s+='<g data-more="1" style="cursor:pointer;">';
24294 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24295 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24296 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>';
24297 s+='</g>';
24298 }
24299 s+='</svg>';
24300 el.innerHTML=s;
24301 wireScatterLegend(el);
24302 var moreEl=el.querySelector('g[data-more]');
24303 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24304 }
24305 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24306
24307 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24308 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24309 // the old vertical column layout on wide containers.
24310 function renderSemanticInEl(el,key,sh){
24311 if(!el||!SEM_D||!SEM_D.length)return;
24312 var n2=SEM_D.length||1;
24313 var LW=112,SH=sh||Math.max(180,n2*28+26);
24314 var svgW=Math.max(320,el.offsetWidth||480);
24315 var BW=Math.max(120,svgW-LW-80);
24316 var topPad=4,botPad=14;
24317 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24318 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24319 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24320 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">';
24321 SEM_D.forEach(function(d,i){
24322 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24323 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>';
24324 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"/>';
24325 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>';
24326 });
24327 s+='</svg>';
24328 el.innerHTML=s;
24329 }
24330 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24331 var semSel=document.getElementById('r-semantic-metric');
24332 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24333 var semExpand=document.getElementById('r-semantic-expand');
24334 if(semExpand){
24335 semExpand.addEventListener('click',function(){
24336 var key=semSel?semSel.value:'functions';
24337 var n=SEM_D.length||1;
24338 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24339 var modalH=Math.min(Math.max(360,n*38+60),maxH);
24340 var overlay=document.createElement('div');
24341 overlay.className='r-chart-modal-overlay';
24342 var optHtml=
24343 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24344 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24345 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24346 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24347 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24348 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>';
24349 document.body.appendChild(overlay);
24350 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24351 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24352 var modalEl=document.getElementById('r-sem-modal-chart');
24353 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24354 var modalSel=document.getElementById('r-sem-modal-metric');
24355 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24356 });
24357 }
24358
24359 // ── Expand buttons: re-render charts at large size inside modal ──────────
24360 (function(){
24361 function makeExpandModal(title,mH,subtitle,ctrlHtml){
24362 var overlay=document.createElement('div');
24363 overlay.className='r-chart-modal-overlay';
24364 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24365 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24366 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>';
24367 document.body.appendChild(overlay);
24368 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24369 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24370 return overlay.querySelector('.r-expand-modal-chart');
24371 }
24372 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24373 var compExpandBtn=document.getElementById('r-composition-expand');
24374 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24375 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24376 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24377 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24378 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24379 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24380 if(wrap){
24381 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24382 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24383 btn.addEventListener('click',function(){
24384 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24385 btn.classList.add('active');
24386 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24387 });
24388 });
24389 }
24390 });}
24391 var scatExpandBtn=document.getElementById('r-scatter-expand');
24392 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24393 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24394 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24395 });}
24396 var densExpandBtn=document.getElementById('r-density-expand');
24397 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24398 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24399 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24400 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24401 });}
24402 var avgExpandBtn=document.getElementById('r-avglines-expand');
24403 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24404 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24405 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24406 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24407 });}
24408 var subExpandBtn=document.getElementById('r-submodule-expand');
24409 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24410 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24411 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24412 var metCtrl=
24413 '<select class="r-chart-select" id="r-sub-modal-metric">'
24414 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24415 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24416 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24417 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24418 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24419 +'</select>';
24420 var sortCtrl=
24421 '<select class="r-chart-select" id="r-sub-modal-sort">'
24422 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24423 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24424 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24425 +'</select>';
24426 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24427 if(wrap){
24428 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24429 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24430 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24431 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24432 if(mSub)mSub.addEventListener('change',reRenderSub);
24433 if(mSort)mSort.addEventListener('change',reRenderSub);
24434 }
24435 });}
24436 })();
24437
24438 // ── Comment Density: comments / (code + comments) per language ───────────
24439 function renderDensityInEl(el,shOvr){
24440 if(!el||!LANG_D||!LANG_D.length)return;
24441 var n=LANG_D.length||1;
24442 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24443 var svgW=Math.max(320,el.offsetWidth||480);
24444 var BW=Math.max(120,svgW-LW-80);
24445 var topPad=4,botPad=26;
24446 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24447 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24448 var densities=LANG_D.map(function(d){
24449 var sig=(d.code||0)+(d.comments||0);
24450 return sig>0?(d.comments||0)/sig:0;
24451 });
24452 var maxDen=Math.max.apply(null,densities)||1;
24453 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">';
24454 LANG_D.forEach(function(d,i){
24455 var den=densities[i],bw=den/maxDen*BW;
24456 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24457 var pct=Math.round(den*100);
24458 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>';
24459 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"/>';
24460 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24461 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>';
24462 });
24463 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>';
24464 s+='</svg>';
24465 el.innerHTML=s;
24466 }
24467 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24468 renderDensity();
24469
24470 // ── Avg Lines per File: code / files per language ─────────────────────
24471 function renderAvgLinesInEl(el,shOvr){
24472 if(!el||!LANG_D||!LANG_D.length)return;
24473 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24474 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24475 var n=data.length||1;
24476 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24477 var svgW=Math.max(320,el.offsetWidth||480);
24478 var BW=Math.max(120,svgW-LW-80);
24479 var topPad=4,botPad=26;
24480 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24481 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24482 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24483 var maxAvg=Math.max.apply(null,avgs)||1;
24484 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">';
24485 data.forEach(function(d,i){
24486 var avg=avgs[i],bw=avg/maxAvg*BW;
24487 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24488 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>';
24489 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"/>';
24490 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24491 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>';
24492 });
24493 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>';
24494 s+='</svg>';
24495 el.innerHTML=s;
24496 }
24497 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24498 renderAvgLines();
24499
24500 // ── Repository Overview: overall row + per-submodule rows ────────────
24501 function renderSubmoduleInEl(el,key,sort,shOvr){
24502 if(!el)return;
24503 var overall={
24504 name:'Overall',
24505 code:{{ code_lines }},
24506 comment:{{ comment_lines }},
24507 blank:{{ blank_lines }},
24508 physical:{{ physical_lines }},
24509 files:{{ files_analyzed }},
24510 isOverall:true
24511 };
24512 var subs=SUB_D.slice();
24513 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24514 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24515 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24516 var data=[overall].concat(subs);
24517 var sepH=subs.length>0?14:0;
24518 var naturalH=data.length*32+sepH+16;
24519 var SH=shOvr||Math.max(100,naturalH);
24520 var svgW=Math.max(320,el.offsetWidth||480);
24521 var LW=116,BW=Math.max(200,svgW-LW-54);
24522 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24523 var OVERALL_COL='#6b7280';
24524 var topPad=4,botPad=8;
24525 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24526 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24527 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">';
24528 var yOff=topPad;
24529 data.forEach(function(d,i){
24530 var v=d[key]||0,bw=v/maxV*BW;
24531 var y=yOff+Math.floor((rowSlot-bH)/2);
24532 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24533 var label=d.name||d.path||'?';
24534 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>';
24535 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24536 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24537 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>';
24538 yOff+=rowSlot;
24539 if(d.isOverall&&subs.length>0){
24540 yOff+=sepH;
24541 }
24542 });
24543 s+='</svg>';
24544 el.innerHTML=s;
24545 }
24546 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24547 var subSel=document.getElementById('r-sub-metric');
24548 var sortSel=document.getElementById('r-sub-sort');
24549 renderSubmodule('code','desc');
24550 if(subSel){
24551 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24552 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24553 }
24554
24555 // Equalise heights within each chart row: if one chart in a grid row is taller
24556 // than its neighbour, re-render the shorter one at the taller height so bars fill
24557 // the available vertical space instead of leaving a gap.
24558 function syncRowHeights(){
24559 var avgEl=document.getElementById('r-avglines-chart');
24560 var subEl=document.getElementById('r-submodule-chart');
24561 if(avgEl&&subEl){
24562 var avgSvg=avgEl.querySelector('svg');
24563 var subSvg=subEl.querySelector('svg');
24564 if(avgSvg&&subSvg){
24565 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24566 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24567 var key=subSel?subSel.value||'code':'code';
24568 var sort=sortSel?sortSel.value:'desc';
24569 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24570 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24571 }
24572 }
24573 var semEl=document.getElementById('r-semantic-chart');
24574 var denEl=document.getElementById('r-density-chart');
24575 if(semEl&&denEl){
24576 var semSvg=semEl.querySelector('svg');
24577 var denSvg=denEl.querySelector('svg');
24578 if(semSvg&&denSvg){
24579 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24580 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24581 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24582 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24583 }
24584 }
24585 }
24586 syncRowHeights();
24587
24588 // Re-render all SVG charts when the window is resized so bars fill the card.
24589 var _rResizeTimer;
24590 window.addEventListener('resize',function(){
24591 clearTimeout(_rResizeTimer);
24592 _rResizeTimer=setTimeout(function(){
24593 var rcompBtn=document.querySelector('[data-rcomp].active');
24594 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24595 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24596 if(semSel)renderSemantic(semSel.value||'functions');
24597 renderDensity();
24598 renderAvgLines();
24599 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24600 syncRowHeights();
24601 },120);
24602 });
24603 })();
24604
24605 (function randomizeWatermarks() {
24606 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24607 if (!wms.length) return;
24608 var placed = [];
24609 function tooClose(top, left) {
24610 for (var i = 0; i < placed.length; i++) {
24611 var dt = Math.abs(placed[i][0] - top);
24612 var dl = Math.abs(placed[i][1] - left);
24613 if (dt < 20 && dl < 18) return true;
24614 }
24615 return false;
24616 }
24617 function pick(leftBand) {
24618 for (var attempt = 0; attempt < 50; attempt++) {
24619 var top = Math.random() * 85 + 5;
24620 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24621 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24622 }
24623 var top = Math.random() * 85 + 5;
24624 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24625 placed.push([top, left]);
24626 return [top, left];
24627 }
24628 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24629 var half = Math.floor(wms.length / 2);
24630 wms.forEach(function (img, i) {
24631 var pos = pick(i < half);
24632 var size = Math.floor(Math.random() * 100 + 160);
24633 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24634 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24635 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;
24636 });
24637 })();
24638
24639 (function spawnCodeParticles() {
24640 var container = document.getElementById('code-particles');
24641 if (!container) return;
24642 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'];
24643 for (var i = 0; i < 38; i++) {
24644 (function(idx) {
24645 var el = document.createElement('span');
24646 el.className = 'code-particle';
24647 el.textContent = snippets[idx % snippets.length];
24648 var left = Math.random() * 94 + 2;
24649 var top = Math.random() * 88 + 6;
24650 var dur = (Math.random() * 10 + 9).toFixed(1);
24651 var delay = (Math.random() * 18).toFixed(1);
24652 var rot = (Math.random() * 26 - 13).toFixed(1);
24653 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24654 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';
24655 container.appendChild(el);
24656 })(i);
24657 }
24658 })();
24659
24660 {% if pdf_generating %}
24661 // Poll for PDF readiness and swap the disabled button to a live link once done.
24662 (function() {
24663 var openBtn = document.getElementById('pdf-open-btn');
24664 var dlBtn = document.getElementById('pdf-download-btn');
24665 function checkPdf() {
24666 fetch('/api/runs/{{ run_id }}/pdf-status')
24667 .then(function(r) { return r.json(); })
24668 .then(function(d) {
24669 if (d.ready) {
24670 if (openBtn) {
24671 var a = document.createElement('a');
24672 a.className = 'button';
24673 a.id = 'pdf-open-btn';
24674 a.href = '/runs/pdf/{{ run_id }}';
24675 a.target = '_blank';
24676 a.rel = 'noopener';
24677 a.textContent = 'Open PDF';
24678 openBtn.replaceWith(a);
24679 }
24680 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24681 } else {
24682 setTimeout(checkPdf, 3000);
24683 }
24684 })
24685 .catch(function() { setTimeout(checkPdf, 5000); });
24686 }
24687 setTimeout(checkPdf, 3000);
24688 })();
24689 {% endif %}
24690
24691 })();
24692 </script>
24693 <script nonce="{{ csp_nonce }}">
24694 (function(){
24695 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'}];
24696 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);});}
24697 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24698 function init(){
24699 var btn=document.getElementById('settings-btn');if(!btn)return;
24700 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24701 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>';
24702 document.body.appendChild(m);
24703 var g=document.getElementById('scheme-grid');
24704 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);});
24705 var cl=document.getElementById('settings-close');
24706 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
24707 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');});
24708 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24709 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24710 }
24711 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24712 }());
24713 </script>
24714 <footer class="site-footer">
24715 local code analysis - metrics, history and reports
24716 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24717 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24718 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24719 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24720 · <a href="/api-docs" rel="noopener">REST API</a>
24721 </footer>
24722 {% if confluence_configured %}
24723 <script nonce="{{ csp_nonce }}">
24724 (function() {
24725 var postBtn = document.getElementById('postConfluenceBtn');
24726 var copyBtn = document.getElementById('copyWikiBtn');
24727 var modal = document.getElementById('confluenceModal');
24728 if (!postBtn || !modal) return;
24729
24730 postBtn.addEventListener('click', function() {
24731 document.getElementById('confStatus').style.display = 'none';
24732 modal.style.display = 'flex';
24733 });
24734 document.getElementById('confCancelBtn').addEventListener('click', function() {
24735 modal.style.display = 'none';
24736 });
24737 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24738
24739 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24740 var btn = this;
24741 btn.disabled = true;
24742 var status = document.getElementById('confStatus');
24743 status.style.display = 'block';
24744 status.style.background = '#dbeafe';
24745 status.style.color = '#1e40af';
24746 status.textContent = 'Posting to Confluence\u2026';
24747 var resp = await fetch('/api/confluence/post', {
24748 method: 'POST',
24749 headers: { 'Content-Type': 'application/json' },
24750 body: JSON.stringify({
24751 run_id: '{{ run_id }}',
24752 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24753 report_url: document.getElementById('confReportUrl').value.trim() || null
24754 })
24755 });
24756 var data = await resp.json();
24757 if (data.ok) {
24758 status.style.background = '#dcfce7'; status.style.color = '#166534';
24759 status.textContent = 'Posted! Page ID: ' + data.page_id;
24760 } else {
24761 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24762 status.textContent = 'Error: ' + (data.error || 'Unknown error');
24763 }
24764 btn.disabled = false;
24765 });
24766
24767 if (copyBtn) {
24768 copyBtn.addEventListener('click', async function() {
24769 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24770 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24771 var text = await resp.text();
24772 try {
24773 await navigator.clipboard.writeText(text);
24774 var orig = copyBtn.textContent;
24775 copyBtn.textContent = 'Copied!';
24776 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24777 } catch(e) {
24778 alert('Clipboard write failed \u2014 check browser permissions.');
24779 }
24780 });
24781 }
24782 })();
24783 </script>
24784 {% endif %}
24785 <script nonce="{{ csp_nonce }}">
24786 (function() {
24787 var deleteBtn = document.getElementById('delete-run-btn');
24788 var modal = document.getElementById('delete-run-modal');
24789 var cancelBtn = document.getElementById('delete-run-cancel');
24790 var confirmBtn= document.getElementById('delete-run-confirm');
24791 if (!deleteBtn || !modal) return;
24792 deleteBtn.addEventListener('click', function() {
24793 document.getElementById('delete-run-status').style.display = 'none';
24794 modal.style.display = 'flex';
24795 });
24796 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24797 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24798 confirmBtn.addEventListener('click', async function() {
24799 confirmBtn.disabled = true;
24800 cancelBtn.disabled = true;
24801 var status = document.getElementById('delete-run-status');
24802 status.style.display = 'block';
24803 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24804 status.textContent = 'Deleting\u2026';
24805 try {
24806 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24807 if (resp.status === 204 || resp.ok) {
24808 status.style.background = '#dcfce7'; status.style.color = '#166534';
24809 status.textContent = 'Deleted. Redirecting\u2026';
24810 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24811 } else {
24812 var d = await resp.json().catch(function(){return {};});
24813 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24814 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24815 confirmBtn.disabled = false;
24816 cancelBtn.disabled = false;
24817 }
24818 } catch (e) {
24819 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24820 status.textContent = 'Network error: ' + String(e);
24821 confirmBtn.disabled = false;
24822 cancelBtn.disabled = false;
24823 }
24824 });
24825 })();
24826 </script>
24827 <script nonce="{{ csp_nonce }}">(function(){
24828 var bundleBtn = document.getElementById('download-bundle-btn');
24829 if (bundleBtn) {
24830 bundleBtn.addEventListener('click', function() {
24831 bundleBtn.disabled = true;
24832 var orig = bundleBtn.textContent;
24833 bundleBtn.textContent = 'Preparing\u2026';
24834 fetch('/api/runs/{{ run_id }}/bundle')
24835 .then(function(r) {
24836 if (!r.ok) throw new Error('HTTP ' + r.status);
24837 return r.blob();
24838 })
24839 .then(function(blob) {
24840 var url = URL.createObjectURL(blob);
24841 var a = document.createElement('a');
24842 a.href = url;
24843 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24844 document.body.appendChild(a);
24845 a.click();
24846 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24847 bundleBtn.disabled = false;
24848 bundleBtn.textContent = orig;
24849 })
24850 .catch(function(e) {
24851 bundleBtn.disabled = false;
24852 bundleBtn.textContent = orig;
24853 alert('Bundle download failed: ' + String(e));
24854 });
24855 });
24856 }
24857 })();</script>
24858 <script nonce="{{ csp_nonce }}">(function(){
24859 var dot=document.getElementById('status-dot');
24860 var pingEl=document.getElementById('server-ping-ms');
24861 var tipEl=document.getElementById('server-tip-ping');
24862 var fm=document.getElementById('footer-mode');
24863 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)';}}
24864 function doPing(){
24865 var t0=performance.now();
24866 fetch('/healthz',{cache:'no-store'})
24867 .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);})
24868 .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)';}});
24869 }
24870 doPing();
24871 setInterval(doPing,5000);
24872 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');}
24873 })();</script>
24874 <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>
24875 {% if let Some(banner) = report_header_footer %}
24876 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24877 {% endif %}
24878</body>
24879</html>
24880"##,
24881 ext = "html"
24882)]
24883#[allow(clippy::struct_excessive_bools)]
24885struct ResultTemplate {
24886 version: &'static str,
24887 report_title: String,
24888 project_path: String,
24889 output_dir: String,
24890 run_id: String,
24891 files_analyzed: u64,
24892 files_skipped: u64,
24893 physical_lines: u64,
24894 code_lines: u64,
24895 comment_lines: u64,
24896 blank_lines: u64,
24897 mixed_lines: u64,
24898 functions: u64,
24899 classes: u64,
24900 variables: u64,
24901 imports: u64,
24902 html_url: Option<String>,
24903 pdf_url: Option<String>,
24904 json_url: Option<String>,
24905 html_download_url: Option<String>,
24906 pdf_download_url: Option<String>,
24907 json_download_url: Option<String>,
24908 html_path: Option<String>,
24909 json_path: Option<String>,
24910 prev_run_id: Option<String>,
24911 prev_run_timestamp: Option<String>,
24912 prev_run_code_lines: Option<u64>,
24913 prev_fa_str: String,
24915 prev_fs_str: String,
24916 prev_pl_str: String,
24917 prev_cl_str: String,
24918 prev_cml_str: String,
24919 prev_bl_str: String,
24920 delta_fa_str: String,
24922 delta_fa_class: String,
24923 delta_fs_str: String,
24924 delta_fs_class: String,
24925 delta_pl_str: String,
24926 delta_pl_class: String,
24927 delta_cl_str: String,
24928 delta_cl_class: String,
24929 delta_cml_str: String,
24930 delta_cml_class: String,
24931 delta_bl_str: String,
24932 delta_bl_class: String,
24933 delta_lines_added: Option<i64>,
24935 delta_lines_removed: Option<i64>,
24936 delta_lines_net_str: String,
24937 delta_lines_net_class: String,
24938 delta_files_added: Option<usize>,
24939 delta_files_removed: Option<usize>,
24940 delta_files_modified: Option<usize>,
24941 delta_files_unchanged: Option<usize>,
24942 delta_unmodified_lines: Option<u64>,
24943 git_branch: Option<String>,
24945 git_branch_url: Option<String>,
24946 git_commit: Option<String>,
24947 git_commit_long: Option<String>,
24948 git_author: Option<String>,
24949 git_commit_url: Option<String>,
24950 scan_performed_by: String,
24952 scan_time_display: String,
24953 os_display: String,
24954 test_count: u64,
24955 test_assertion_count: u64,
24957 prev_scan_count: usize,
24959 current_scan_number: usize,
24960 submodule_rows: Vec<SubmoduleRow>,
24962 scan_config_url: String,
24963 lang_chart_json: String,
24964 #[allow(dead_code)]
24966 scatter_chart_json: String,
24967 #[allow(dead_code)]
24968 semantic_chart_json: String,
24969 #[allow(dead_code)]
24970 submodule_chart_json: String,
24971 #[allow(dead_code)]
24972 has_submodule_data: bool,
24973 #[allow(dead_code)]
24974 has_semantic_data: bool,
24975 pdf_generating: bool,
24976 csp_nonce: String,
24977 confluence_configured: bool,
24979 server_mode: bool,
24980 report_header_footer: Option<String>,
24982 run_id_short: String,
24983 #[allow(dead_code)]
24985 is_offline: bool,
24986 cyclomatic_complexity: u64,
24988 lsloc: Option<u64>,
24990 uloc: u64,
24992 dryness_pct_str: String,
24994 duplicate_group_count: usize,
24996 has_cocomo: bool,
24998 cocomo_effort_str: String,
25000 cocomo_duration_str: String,
25002 cocomo_staff_str: String,
25004 cocomo_ksloc_str: String,
25006 cocomo_mode_label: String,
25008 cocomo_mode_tooltip: String,
25010 complexity_alert: u32,
25012 has_coverage_data: bool,
25014 cov_line_pct: String,
25016 cov_fn_pct: String,
25018 cov_branch_pct: String,
25020 cov_lines_summary: String,
25022}
25023
25024#[derive(Template)]
25025#[template(
25026 source = r##"
25027<!doctype html>
25028<html lang="en">
25029<head>
25030 <meta charset="utf-8">
25031 <meta name="viewport" content="width=device-width, initial-scale=1">
25032 <title>OxideSLOC | Analyzing…</title>
25033 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25034 <style nonce="{{ csp_nonce }}">
25035 :root {
25036 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25037 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25038 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25039 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25040 }
25041 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25042 *{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;}
25043 .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);}
25044 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25045 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25046 .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));}
25047 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25048 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25049 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25050 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25051 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25052 @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; } }
25053 .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;}
25054 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25055 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25056 .page-body{padding:32px 24px 36px;}
25057 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25058 .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;}
25059 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25060 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25061 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25062 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25063 .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;}
25064 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25065 .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;}
25066 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25067 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25068 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25069 .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;}
25070 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25071 .hidden{display:none!important;}
25072 .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;}
25073 .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;}
25074 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25075 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25076 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25077 .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);}
25078 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25079 .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;}
25080 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25081 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25082 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25083 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25084 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25085 .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;}
25086 @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));}}
25087 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25088 .site-footer a{color:var(--muted);}
25089 .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;}
25090 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25091 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25092 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25093 </style>
25094</head>
25095<body>
25096 <div class="background-watermarks" aria-hidden="true">
25097 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25098 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25099 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25100 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25101 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25102 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25103 </div>
25104 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25105 <nav class="top-nav">
25106 <div class="top-nav-inner">
25107 <a href="/" class="brand">
25108 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25109 <div class="brand-copy">
25110 <h1 class="brand-title">OxideSLOC</h1>
25111 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25112 </div>
25113 </a>
25114 <div class="nav-right">
25115 <a class="nav-pill" href="/">Home</a>
25116 <div class="nav-dropdown">
25117 <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>
25118 <div class="nav-dropdown-menu">
25119 <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>
25120 </div>
25121 </div>
25122 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25123 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25124 <div class="nav-dropdown">
25125 <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>
25126 <div class="nav-dropdown-menu">
25127 <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>
25128 </div>
25129 </div>
25130 <div class="server-status-wrap" id="server-status-wrap">
25131 <div class="nav-pill server-online-pill" id="server-status-pill">
25132 <span class="status-dot" id="status-dot"></span>
25133 <span id="server-status-label">Server</span>
25134 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25135 </div>
25136 <div class="server-status-tip">
25137 OxideSLOC is running — accessible on your network.
25138 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25139 </div>
25140 </div>
25141 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25142 <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>
25143 </button>
25144 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25145 <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>
25146 <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>
25147 </button>
25148 </div>
25149 </div>
25150 </nav>
25151 <div class="page-body">
25152 <div class="wait-panel">
25153 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25154 <h2 class="wait-title">Analyzing your project…</h2>
25155 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25156 <div class="path-block">{{ project_path }}</div>
25157 <div class="metrics-row">
25158 <div class="metric-card">
25159 <div class="metric-label">Elapsed</div>
25160 <div class="metric-value" id="elapsed">0s</div>
25161 </div>
25162 <div class="metric-card">
25163 <div class="metric-label">Phase</div>
25164 <div class="metric-value" id="phase">Starting</div>
25165 </div>
25166 <div class="metric-card hidden" id="files-card">
25167 <div class="metric-label">Files</div>
25168 <div class="metric-value" id="files-progress">0</div>
25169 </div>
25170 </div>
25171 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25172 <div class="warn-slow hidden" id="warn-slow">
25173 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.
25174 </div>
25175 <div class="err-panel hidden" id="err-panel">
25176 <strong>Analysis failed</strong>
25177 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25178 </div>
25179 <div class="actions hidden" id="actions">
25180 <a href="/scan" class="btn-primary">Try Again</a>
25181 <a href="/view-reports" class="btn-outline">View Reports</a>
25182 </div>
25183 </div>
25184 </div>
25185 <script nonce="{{ csp_nonce }}">
25186 (function() {
25187 var WAIT_ID = {{ wait_id_json|safe }};
25188 var startTime = Date.now();
25189 var pollInterval = 1500;
25190 var retries = 0;
25191 var maxRetries = 5;
25192 var warnShown = false;
25193
25194 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();}
25195
25196 function elapsed() {
25197 return Math.floor((Date.now() - startTime) / 1000);
25198 }
25199
25200 function updateElapsed() {
25201 var s = elapsed();
25202 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25203 }
25204
25205 function setPhase(txt) {
25206 document.getElementById('phase').textContent = txt;
25207 }
25208
25209 var elapsedTimer = setInterval(updateElapsed, 1000);
25210
25211 function poll() {
25212 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25213 .then(function(r) {
25214 if (!r.ok) throw new Error('HTTP ' + r.status);
25215 return r.json();
25216 })
25217 .then(function(data) {
25218 retries = 0;
25219 if (data.state === 'complete') {
25220 clearInterval(elapsedTimer);
25221 setPhase('Done');
25222 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25223 } else if (data.state === 'failed') {
25224 clearInterval(elapsedTimer);
25225 setPhase('Failed');
25226 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25227 document.getElementById('err-panel').classList.remove('hidden');
25228 document.getElementById('actions').classList.remove('hidden');
25229 } else {
25230 // still running
25231 var s = elapsed();
25232 if (s > 90 && !warnShown) {
25233 warnShown = true;
25234 document.getElementById('warn-slow').classList.remove('hidden');
25235 }
25236 setPhase(data.phase || 'Running');
25237 var fd = data.files_done || 0, ft = data.files_total || 0;
25238 if (ft > 0) {
25239 var card = document.getElementById('files-card');
25240 if (card) card.classList.remove('hidden');
25241 var fp = document.getElementById('files-progress');
25242 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25243 }
25244 setTimeout(poll, pollInterval);
25245 }
25246 })
25247 .catch(function(err) {
25248 retries++;
25249 if (retries >= maxRetries) {
25250 clearInterval(elapsedTimer);
25251 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25252 document.getElementById('err-panel').classList.remove('hidden');
25253 document.getElementById('actions').classList.remove('hidden');
25254 } else {
25255 // exponential back-off capped at 8s
25256 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25257 }
25258 });
25259 }
25260
25261 setTimeout(poll, pollInterval);
25262
25263 // If the browser restores this page from bfcache (Back after viewing results),
25264 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25265 window.addEventListener("pageshow", function(e) {
25266 if (e.persisted) { setTimeout(poll, 200); }
25267 });
25268 })();
25269 </script>
25270 <footer class="site-footer">
25271 local code analysis - metrics, history and reports
25272 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25273 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25274 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25275 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25276 · <a href="/api-docs" rel="noopener">REST API</a>
25277 </footer>
25278 <script nonce="{{ csp_nonce }}">
25279 (function(){
25280 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25281 if(s==="dark")b.classList.add("dark-theme");
25282 var tt=document.getElementById("theme-toggle");
25283 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25284 })();
25285 (function spawnCodeParticles(){
25286 var c=document.getElementById('code-particles');if(!c)return;
25287 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'];
25288 for(var i=0;i<32;i++){(function(idx){
25289 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25290 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25291 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25292 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25293 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25294 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25295 c.appendChild(el);
25296 })(i);}
25297 })();
25298 (function randomizeWatermarks(){
25299 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25300 var placed=[];
25301 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;}
25302 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];}
25303 var half=Math.floor(wms.length/2);
25304 wms.forEach(function(img,i){
25305 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25306 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25307 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25308 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25309 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25310 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25311 });
25312 })();
25313 </script>
25314 <script nonce="{{ csp_nonce }}">
25315 (function(){
25316 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'}];
25317 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);});}
25318 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25319 function init(){
25320 var btn=document.getElementById('settings-btn');if(!btn)return;
25321 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25322 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>';
25323 document.body.appendChild(m);
25324 var g=document.getElementById('scheme-grid');
25325 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);});
25326 var cl=document.getElementById('settings-close');
25327 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
25328 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');});
25329 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25330 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25331 }
25332 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25333 }());
25334 </script>
25335 <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]';
25336 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;}
25337 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>
25338</body>
25339</html>
25340"##,
25341 ext = "html"
25342)]
25343struct ScanWaitTemplate {
25344 version: &'static str,
25345 wait_id_json: String,
25346 project_path: String,
25347 csp_nonce: String,
25348}
25349
25350#[derive(Template)]
25351#[template(
25352 source = r##"
25353<!doctype html>
25354<html lang="en">
25355<head>
25356 <meta charset="utf-8">
25357 <meta name="viewport" content="width=device-width, initial-scale=1">
25358 <title>OxideSLOC | Error</title>
25359 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25360 <style nonce="{{ csp_nonce }}">
25361 :root {
25362 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25363 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25364 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25365 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25366 }
25367 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25368 *{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;}
25369 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25370 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25371 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25372 .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);}
25373 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25374 .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));}
25375 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25376 .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;}
25377 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25378 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25379 @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; } }
25380 .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;}
25381 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25382 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25383 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25384 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25385 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25386 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
25387 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25388 .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);}
25389 .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;}
25390 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25391 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25392 .settings-modal-body{padding:14px 16px 16px;}
25393 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25394 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25395 .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;}
25396 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25397 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25398 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25399 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25400 .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;}
25401 .tz-select:focus{border-color:var(--oxide);}
25402 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25403 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25404 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25405 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25406 .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;}
25407 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25408 .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);}
25409 .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;}
25410 .btn-secondary:hover{background:var(--line);}
25411 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25412 .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;}
25413 .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;}
25414 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25415 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25416 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25417 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25418 .bug-report-panel.open{display:flex;}
25419 .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;}
25420 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25421 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25422 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25423 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25424 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25425 .br-network-badge.online .br-net-dot{background:#2a6846;}
25426 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25427 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25428 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25429 .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;}
25430 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25431 .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;}
25432 .btn-sm:hover{background:var(--line);}
25433 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25434 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25435 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25436 .bug-report-hint a:hover{text-decoration:underline;}
25437 .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;}
25438 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25439 .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;}
25440 .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;}
25441 .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;}
25442 @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));}}
25443 .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;}
25444 </style>
25445</head>
25446<body>
25447 <div class="background-watermarks" aria-hidden="true">
25448 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25449 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25450 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25451 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25452 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25453 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25454 </div>
25455 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25456 <div class="top-nav">
25457 <div class="top-nav-inner">
25458 <a class="brand" href="/">
25459 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25460 <div class="brand-copy">
25461 <div class="brand-title">OxideSLOC</div>
25462 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25463 </div>
25464 </a>
25465 <div class="nav-right">
25466 <a class="nav-pill" href="/">Home</a>
25467 <div class="nav-dropdown">
25468 <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>
25469 <div class="nav-dropdown-menu">
25470 <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>
25471 </div>
25472 </div>
25473 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25474 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25475 <div class="nav-dropdown">
25476 <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>
25477 <div class="nav-dropdown-menu">
25478 <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>
25479 </div>
25480 </div>
25481 <div class="server-status-wrap" id="server-status-wrap">
25482 <div class="nav-pill server-online-pill" id="server-status-pill">
25483 <span class="status-dot" id="status-dot"></span>
25484 <span id="server-status-label">Server</span>
25485 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25486 </div>
25487 <div class="server-status-tip">
25488 OxideSLOC is running — accessible on your network.
25489 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25490 </div>
25491 </div>
25492 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25493 <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>
25494 </button>
25495 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25496 <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>
25497 <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>
25498 </button>
25499 </div>
25500 </div>
25501 </div>
25502
25503 <div class="page">
25504 <div class="panel">
25505 <h1>Error</h1>
25506 <div class="error-box" id="error-msg-text">{{ message }}</div>
25507 <div id="br-meta" hidden
25508 data-version="{{ version }}"
25509 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25510 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25511 <div class="actions">
25512 <a class="btn-primary" href="/scan">Back to setup</a>
25513 {% if let Some(report_url) = last_report_url %}
25514 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25515 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25516 {% else %}
25517 <a class="btn-secondary" href="/view-reports">View Reports</a>
25518 {% endif %}
25519 </div>
25520 <div class="bug-report-section" id="bug-report-section">
25521 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25522 <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>
25523 Generate Bug Report
25524 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25525 </button>
25526 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25527 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
25528 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
25529 <div class="bug-report-btns">
25530 <button type="button" class="btn-sm" id="bug-report-copy">
25531 <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>
25532 Copy to clipboard
25533 </button>
25534 <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;">
25535 <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>
25536 Open GitHub Issue
25537 </a>
25538 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25539 <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>
25540 Save as file
25541 </button>
25542 </div>
25543 <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>
25544 <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>
25545 </div>
25546 </div>
25547 </div>
25548 </div>
25549 <footer class="site-footer">
25550 oxide-sloc v{{ version }} — local code metrics workbench ·
25551 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25552 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25553 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25554 · <a href="/api-docs" rel="noopener">REST API</a>
25555 </footer>
25556 <script nonce="{{ csp_nonce }}">(function(){
25557 var meta=document.getElementById('br-meta');
25558 var pre=document.getElementById('bug-report-pre');
25559 var copyBtn=document.getElementById('bug-report-copy');
25560 var trigger=document.getElementById('bug-report-trigger');
25561 var panel=document.getElementById('bug-report-panel');
25562 var networkBadge=document.getElementById('br-network-badge');
25563 var networkLabel=document.getElementById('br-network-label');
25564 var ghLink=document.getElementById('bug-report-github-link');
25565 var saveBtn=document.getElementById('bug-report-save');
25566 var hintOnline=document.getElementById('br-hint-online');
25567 var hintOffline=document.getElementById('br-hint-offline');
25568 if(!meta||!pre)return;
25569 var ver=meta.getAttribute('data-version')||'';
25570 var runId=meta.getAttribute('data-run-id')||'';
25571 var code=meta.getAttribute('data-error-code')||'';
25572 var msgEl=document.getElementById('error-msg-text');
25573 var msg=msgEl?msgEl.textContent.trim():'';
25574 function getBrowser(){
25575 var ua=navigator.userAgent;
25576 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25577 if(!m)return 'Unknown browser';
25578 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25579 return n+' '+m[2];
25580 }
25581 var lines=['oxide-sloc Bug Report','==============================',''];
25582 lines.push('App version: v'+ver);
25583 if(code)lines.push('HTTP status: '+code);
25584 if(runId)lines.push('Run ID: '+runId);
25585 lines.push('Page: '+window.location.pathname+(window.location.search||''));
25586 lines.push('Timestamp: '+new Date().toISOString());
25587 lines.push('Browser: '+getBrowser());
25588 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
25589 lines.push('');
25590 lines.push('Error message:');
25591 lines.push(msg);
25592 lines.push('');
25593 lines.push('Steps to reproduce:');
25594 lines.push(' 1. ');
25595 lines.push('');
25596 lines.push('Expected behavior:');
25597 lines.push(' ');
25598 pre.textContent=lines.join('\n');
25599 function applyNetwork(online){
25600 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25601 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25602 if(ghLink){
25603 if(online){
25604 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25605 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25606 }
25607 ghLink.style.display=online?'inline-flex':'none';
25608 }
25609 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25610 if(hintOnline)hintOnline.style.display=online?'block':'none';
25611 if(hintOffline)hintOffline.style.display=online?'none':'block';
25612 }
25613 applyNetwork(navigator.onLine);
25614 var probed=false;
25615 function probeNetwork(){
25616 if(probed)return;probed=true;
25617 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25618 var probeIdx=0;
25619 function tryNext(){
25620 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25621 var u=probeUrls[probeIdx++];
25622 var c2=new AbortController();
25623 var t2=setTimeout(function(){c2.abort();},4000);
25624 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25625 .then(function(){clearTimeout(t2);applyNetwork(true);})
25626 .catch(function(){clearTimeout(t2);tryNext();});
25627 }
25628 tryNext();
25629 }
25630 if(trigger&&panel){
25631 trigger.addEventListener('click',function(){
25632 var open=panel.classList.toggle('open');
25633 trigger.classList.toggle('open',open);
25634 trigger.setAttribute('aria-expanded',open?'true':'false');
25635 if(open)probeNetwork();
25636 });
25637 }
25638 if(copyBtn){
25639 copyBtn.addEventListener('click',function(){
25640 var txt=pre.textContent;
25641 if(navigator.clipboard&&navigator.clipboard.writeText){
25642 navigator.clipboard.writeText(txt).then(function(){
25643 copyBtn.textContent='\u2713 Copied!';
25644 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);
25645 });
25646 }else{
25647 var ta=document.createElement('textarea');
25648 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25649 document.body.appendChild(ta);ta.select();
25650 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25651 document.body.removeChild(ta);
25652 }
25653 });
25654 }
25655 if(saveBtn){
25656 saveBtn.addEventListener('click',function(){
25657 var txt=pre.textContent;
25658 var blob=new Blob([txt],{type:'text/plain'});
25659 var url=URL.createObjectURL(blob);
25660 var a=document.createElement('a');
25661 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25662 document.body.appendChild(a);a.click();
25663 document.body.removeChild(a);URL.revokeObjectURL(url);
25664 });
25665 }
25666 })();</script>
25667 <script nonce="{{ csp_nonce }}">
25668 (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");});})();
25669 (function spawnCodeParticles() {
25670 var container = document.getElementById('code-particles');
25671 if (!container) return;
25672 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'];
25673 for (var i = 0; i < 38; i++) {
25674 (function(idx) {
25675 var el = document.createElement('span');
25676 el.className = 'code-particle';
25677 el.textContent = snippets[idx % snippets.length];
25678 var left = Math.random() * 94 + 2;
25679 var top = Math.random() * 88 + 6;
25680 var dur = (Math.random() * 10 + 9).toFixed(1);
25681 var delay = (Math.random() * 18).toFixed(1);
25682 var rot = (Math.random() * 26 - 13).toFixed(1);
25683 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25684 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';
25685 container.appendChild(el);
25686 })(i);
25687 }
25688 })();
25689 (function randomizeWatermarks() {
25690 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25691 var placed = [];
25692 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; }
25693 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]; }
25694 var half = Math.floor(wms.length/2);
25695 wms.forEach(function(img, i) {
25696 var pos = pick(i < half);
25697 var w = Math.floor(Math.random()*60+80);
25698 var rot = (Math.random()*40-20).toFixed(1);
25699 var op = (Math.random()*0.08+0.05).toFixed(2);
25700 var animDur = (Math.random()*6+5).toFixed(1);
25701 var animDelay = (Math.random()*10).toFixed(1);
25702 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';
25703 });
25704 })();
25705 </script>
25706 <script nonce="{{ csp_nonce }}">
25707 (function(){
25708 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'}];
25709 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);});}
25710 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25711 function init(){
25712 var btn=document.getElementById('settings-btn');if(!btn)return;
25713 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25714 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>';
25715 document.body.appendChild(m);
25716 var g=document.getElementById('scheme-grid');
25717 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);});
25718 var cl=document.getElementById('settings-close');
25719 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
25720 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');});
25721 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25722 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25723 }
25724 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25725 }());
25726 </script>
25727 <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]';
25728 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;}
25729 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>
25730</body>
25731</html>
25732"##,
25733 ext = "html"
25734)]
25735struct ErrorTemplate {
25736 message: String,
25737 last_report_url: Option<String>,
25739 last_report_label: Option<String>,
25741 run_id: Option<String>,
25743 error_code: Option<u16>,
25745 csp_nonce: String,
25746 version: &'static str,
25747}
25748
25749#[derive(Template)]
25752#[template(
25753 source = r##"
25754<!doctype html>
25755<html lang="en">
25756<head>
25757 <meta charset="utf-8">
25758 <meta name="viewport" content="width=device-width, initial-scale=1">
25759 <title>OxideSLOC | Locate Report</title>
25760 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25761 <style nonce="{{ csp_nonce }}">
25762 :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);}
25763 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25764 *{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;}
25765 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25766 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25767 .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);}
25768 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25769 .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));}
25770 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25771 .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;}
25772 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25773 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25774 @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;}}
25775 .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;}
25776 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25777 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25778 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25779 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25780 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25781 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
25782 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25783 .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);}
25784 .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;}
25785 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25786 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25787 .settings-modal-body{padding:14px 16px 16px;}
25788 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25789 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25790 .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;}
25791 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25792 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25793 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25794 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25795 .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;}
25796 .tz-select:focus{border-color:var(--oxide);}
25797 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25798 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25799 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25800 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25801 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25802 .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;}
25803 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25804 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25805 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25806 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25807 .locate-row{display:flex;gap:8px;align-items:stretch;}
25808 .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;}
25809 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25810 body.dark-theme .locate-input{background:var(--surface-2);}
25811 .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;}
25812 .warning-banner.show{display:flex;}
25813 .warning-banner svg{flex:0 0 auto;}
25814 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25815 .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;}
25816 .error-inline.show{display:flex;}
25817 .error-inline svg{flex:0 0 auto;margin-top:2px;}
25818 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25819 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25820 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25821 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25822 .err-kv-p{margin:0 0 4px;}
25823 .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;}
25824 .success-inline.show{display:flex;}
25825 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25826 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25827 .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;}
25828 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25829 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25830 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25831 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25832 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25833 .fh-row:last-child{border-bottom:none;}
25834 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25835 .fh-dir{font-weight:800;color:var(--text);}
25836 .fh-hl{color:var(--oxide);font-weight:700;}
25837 .fh-muted{color:var(--muted);}
25838 .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;}
25839 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25840 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25841 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25842 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25843 .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;}
25844 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25845 .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;}
25846 .btn-secondary:hover{background:var(--line);}
25847 .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;}
25848 .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;}
25849 .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;}
25850 @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));}}
25851 .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;}
25852 .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;}
25853 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25854 </style>
25855</head>
25856<body>
25857 <div class="background-watermarks" aria-hidden="true">
25858 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25859 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25860 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25861 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25862 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25863 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25864 </div>
25865 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25866 <div class="top-nav">
25867 <div class="top-nav-inner">
25868 <a class="brand" href="/">
25869 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25870 <div class="brand-copy">
25871 <div class="brand-title">OxideSLOC</div>
25872 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25873 </div>
25874 </a>
25875 <div class="nav-right">
25876 <a class="nav-pill" href="/">Home</a>
25877 <div class="nav-dropdown">
25878 <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>
25879 <div class="nav-dropdown-menu">
25880 <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>
25881 </div>
25882 </div>
25883 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25884 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25885 <div class="nav-dropdown">
25886 <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>
25887 <div class="nav-dropdown-menu">
25888 <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>
25889 </div>
25890 </div>
25891 <div class="server-status-wrap" id="server-status-wrap">
25892 <div class="nav-pill server-online-pill" id="server-status-pill">
25893 <span class="status-dot" id="status-dot"></span>
25894 <span id="server-status-label">Server</span>
25895 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25896 </div>
25897 <div class="server-status-tip">
25898 OxideSLOC is running — accessible on your network.
25899 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25900 </div>
25901 </div>
25902 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25903 <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>
25904 </button>
25905 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25906 <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>
25907 <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>
25908 </button>
25909 </div>
25910 </div>
25911 </div>
25912
25913 <div class="page">
25914 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25915 <div class="panel">
25916 <h1>Report File Not Found</h1>
25917 <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>
25918 <div class="field-label">Missing file</div>
25919 <div class="filename-chip">
25920 <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>
25921 {{ expected_filename }}
25922 </div>
25923 <div class="locate-section">
25924 <h2>Locate Scan Output Folder</h2>
25925 <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>
25926 <p>OxideSLOC will find the correct files inside automatically.</p>
25927 <div class="locate-row">
25928 <input type="text" id="locate-file-input"
25929 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25930 class="locate-input" autocomplete="off" spellcheck="false">
25931 {% if !server_mode %}
25932 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
25933 {% endif %}
25934 </div>
25935 <div class="warning-banner" id="filename-warning">
25936 <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>
25937 <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>
25938 </div>
25939 <div class="error-inline" id="locate-error">
25940 <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>
25941 <span id="locate-error-text"></span>
25942 </div>
25943 <div class="success-inline" id="locate-success">
25944 <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>
25945 <span>Scan restored — loading report…</span>
25946 </div>
25947 <div class="btn-row">
25948 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25949 <a class="btn-secondary" href="/view-reports">View Reports</a>
25950 </div>
25951 <div class="folder-hint-shell">
25952 <div class="folder-hint-hdr">
25953 <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>
25954 Expected Folder Structure — Select the Top-Level Folder
25955 </div>
25956 <div class="folder-hint-body">
25957 <div class="fh-row">
25958 <span class="fh-tog">►</span>
25959 <span class="fh-dir">project_20260601-0029-…/</span>
25960 <span class="fh-badge">← select this</span>
25961 </div>
25962 <div class="fh-row fh-i1">
25963 <span class="fh-tog">►</span>
25964 <span class="fh-dir">html/</span>
25965 </div>
25966 <div class="fh-row fh-i2">
25967 <span class="fh-bul">•</span>
25968 <span class="fh-hl">{{ expected_filename }}</span>
25969 </div>
25970 <div class="fh-row fh-i1">
25971 <span class="fh-tog">►</span>
25972 <span class="fh-dir">json/</span>
25973 </div>
25974 <div class="fh-row fh-i2">
25975 <span class="fh-bul">•</span>
25976 <span class="fh-muted">result_*.json</span>
25977 </div>
25978 <div class="fh-row fh-i1">
25979 <span class="fh-tog">►</span>
25980 <span class="fh-dir">pdf/</span>
25981 </div>
25982 <div class="fh-row fh-i2">
25983 <span class="fh-bul">•</span>
25984 <span class="fh-muted">report_*.pdf</span>
25985 </div>
25986 <div class="fh-row fh-i1">
25987 <span class="fh-tog">►</span>
25988 <span class="fh-dir">excel/</span>
25989 </div>
25990 <div class="fh-row fh-i2">
25991 <span class="fh-bul">•</span>
25992 <span class="fh-muted">report_*.csv report_*.xlsx</span>
25993 </div>
25994 </div>
25995 </div>
25996 </div>
25997 </div>
25998 </div>
25999 <footer class="site-footer">
26000 oxide-sloc v{{ version }} — local code metrics workbench ·
26001 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26002 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26003 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26004 · <a href="/api-docs" rel="noopener">REST API</a>
26005 </footer>
26006 <script nonce="{{ csp_nonce }}">(function(){
26007 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26008 if(s==="dark")b.classList.add("dark-theme");
26009 document.getElementById("theme-toggle").addEventListener("click",function(){
26010 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26011 });
26012 })();</script>
26013 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26014 var c=document.getElementById('code-particles');if(!c)return;
26015 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'];
26016 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);}
26017 })();
26018 (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>
26019 <script nonce="{{ csp_nonce }}">(function(){
26020 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'}];
26021 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);});}
26022 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26023 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);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');});}
26024 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26025 }());</script>
26026 <script nonce="{{ csp_nonce }}">(function(){
26027 var meta=document.getElementById('locate-meta');
26028 var inp=document.getElementById('locate-file-input');
26029 var browseBtn=document.getElementById('browse-locate-btn');
26030 var submitBtn=document.getElementById('locate-submit-btn');
26031 var warning=document.getElementById('filename-warning');
26032 var errBox=document.getElementById('locate-error');
26033 var errText=document.getElementById('locate-error-text');
26034 var okBox=document.getElementById('locate-success');
26035 var expected=meta?meta.getAttribute('data-expected'):'';
26036 var runId=meta?meta.getAttribute('data-run-id'):'';
26037 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26038 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26039 function showErr(msg){
26040 if(errText){
26041 errText.innerHTML='';
26042 var lines=msg.split('\n');
26043 var hasPairs=lines.some(function(l){return / : /.test(l);});
26044 if(!hasPairs){errText.textContent=msg;}
26045 else{
26046 var frag=document.createDocumentFragment();var tbl=null;
26047 lines.forEach(function(line){
26048 var m=line.match(/^(.*?) : (.*)$/);
26049 if(m){
26050 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26051 var tr=document.createElement('tr');
26052 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26053 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26054 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26055 } else {
26056 tbl=null;
26057 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26058 }
26059 });
26060 errText.appendChild(frag);
26061 }
26062 }
26063 if(errBox)errBox.classList.add('show');
26064 if(okBox)okBox.classList.remove('show');
26065 }
26066 function clearErr(){
26067 if(errBox)errBox.classList.remove('show');
26068 if(okBox)okBox.classList.remove('show');
26069 }
26070 function validate(){
26071 var val=inp?inp.value.trim():'';
26072 clearErr();
26073 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26074 if(submitBtn)submitBtn.disabled=false;
26075 if(warning){
26076 var name=basename(val);
26077 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26078 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26079 else warning.classList.remove('show');
26080 }
26081 }
26082 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26083 if(browseBtn){
26084 browseBtn.addEventListener('click',function(){
26085 browseBtn.disabled=true;browseBtn.textContent='...';
26086 fetch('/pick-directory')
26087 .then(function(r){return r.ok?r.json():{cancelled:true};})
26088 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26089 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26090 });
26091 }
26092 if(submitBtn){
26093 submitBtn.addEventListener('click',function(){
26094 var folder=inp?inp.value.trim():'';
26095 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26096 clearErr();
26097 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26098 var body=new URLSearchParams();
26099 body.set('file_path',folder);
26100 body.set('redirect_url',redirectUrl);
26101 body.set('expected_run_id',runId);
26102 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26103 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26104 .then(function(d){
26105 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26106 if(d&&d.ok){
26107 if(okBox)okBox.classList.add('show');
26108 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26109 } else {
26110 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26111 }
26112 })
26113 .catch(function(e){
26114 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26115 showErr('Network error: '+String(e));
26116 });
26117 });
26118 }
26119 })();</script>
26120 <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>
26121</body>
26122</html>
26123"##,
26124 ext = "html"
26125)]
26126struct LocateFileTemplate {
26127 run_id: String,
26128 artifact_type: String,
26129 expected_filename: String,
26130 server_mode: bool,
26131 csp_nonce: String,
26132 version: &'static str,
26133}
26134
26135#[derive(Template)]
26138#[template(
26139 source = r##"
26140<!doctype html>
26141<html lang="en">
26142<head>
26143 <meta charset="utf-8">
26144 <meta name="viewport" content="width=device-width, initial-scale=1">
26145 <title>OxideSLOC | Locate Scan Files</title>
26146 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26147 <style nonce="{{ csp_nonce }}">
26148 :root {
26149 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26150 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26151 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26152 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26153 }
26154 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26155 *{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;}
26156 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26157 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26158 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26159 .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);}
26160 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26161 .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));}
26162 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26163 .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;}
26164 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26165 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26166 @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;}}
26167 .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;}
26168 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26169 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26170 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26171 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26172 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26173 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
26174 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26175 .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);}
26176 .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;}
26177 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26178 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26179 .settings-modal-body{padding:14px 16px 16px;}
26180 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26181 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26182 .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;}
26183 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26184 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26185 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26186 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26187 .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;}
26188 .tz-select:focus{border-color:var(--oxide);}
26189 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26190 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26191 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26192 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26193 .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;}
26194 .error-box.hidden{display:none;}
26195 .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;}
26196 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26197 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26198 .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;}
26199 .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;}
26200 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26201 .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;}
26202 .btn-secondary:hover{background:var(--line);}
26203 .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;}
26204 .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;}
26205 .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;}
26206 @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));}}
26207 .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;}
26208 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26209 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26210 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26211 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26212 .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;}
26213 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26214 body.dark-theme .relocate-input{background:var(--surface-2);}
26215 </style>
26216</head>
26217<body>
26218 <div class="background-watermarks" aria-hidden="true">
26219 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26220 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26221 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26222 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26223 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26224 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26225 </div>
26226 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26227 <div class="top-nav">
26228 <div class="top-nav-inner">
26229 <a class="brand" href="/">
26230 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26231 <div class="brand-copy">
26232 <div class="brand-title">OxideSLOC</div>
26233 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26234 </div>
26235 </a>
26236 <div class="nav-right">
26237 <a class="nav-pill" href="/">Home</a>
26238 <div class="nav-dropdown">
26239 <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>
26240 <div class="nav-dropdown-menu">
26241 <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>
26242 </div>
26243 </div>
26244 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26245 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26246 <div class="nav-dropdown">
26247 <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>
26248 <div class="nav-dropdown-menu">
26249 <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>
26250 </div>
26251 </div>
26252 <div class="server-status-wrap" id="server-status-wrap">
26253 <div class="nav-pill server-online-pill" id="server-status-pill">
26254 <span class="status-dot" id="status-dot"></span>
26255 <span id="server-status-label">Server</span>
26256 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26257 </div>
26258 <div class="server-status-tip">
26259 OxideSLOC is running — accessible on your network.
26260 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26261 </div>
26262 </div>
26263 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26264 <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>
26265 </button>
26266 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26267 <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>
26268 <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>
26269 </button>
26270 </div>
26271 </div>
26272 </div>
26273
26274 <div class="page">
26275 <div class="panel">
26276 <h1>Scan Files Moved</h1>
26277 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26278 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26279 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26280 <div class="relocate-section">
26281 <h2>Locate Scan Output</h2>
26282 <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>
26283 <div class="relocate-row">
26284 <input type="text" id="relocate-folder" name="folder_path"
26285 value="{{ folder_hint }}"
26286 placeholder="Path to folder containing scan output..."
26287 class="relocate-input" autocomplete="off" spellcheck="false">
26288 {% if !server_mode %}
26289 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
26290 {% endif %}
26291 </div>
26292 <div style="margin-top:12px;">
26293 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26294 </div>
26295 </div>
26296 <div class="actions">
26297 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26298 <a class="btn-secondary" href="/view-reports">View Reports</a>
26299 </div>
26300 </div>
26301 </div>
26302 <footer class="site-footer">
26303 oxide-sloc v{{ version }} — local code metrics workbench ·
26304 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26305 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26306 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26307 · <a href="/api-docs" rel="noopener">REST API</a>
26308 </footer>
26309 <script nonce="{{ csp_nonce }}">
26310 (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");});})();
26311 (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);}})();
26312 (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;});})();
26313 </script>
26314 <script nonce="{{ csp_nonce }}">
26315 (function(){
26316 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'}];
26317 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);});}
26318 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26319 function init(){
26320 var btn=document.getElementById('settings-btn');if(!btn)return;
26321 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26322 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>';
26323 document.body.appendChild(m);
26324 var g=document.getElementById('scheme-grid');
26325 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);});
26326 var cl=document.getElementById('settings-close');
26327 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
26328 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');});
26329 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26330 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26331 }
26332 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26333 }());
26334 (function(){
26335 var browseBtn=document.getElementById('browse-relocate-btn');
26336 if(browseBtn){
26337 browseBtn.addEventListener('click',function(){
26338 browseBtn.disabled=true;browseBtn.textContent='...';
26339 var inp=document.getElementById('relocate-folder');
26340 var hint=inp?inp.value:'';
26341 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
26342 .then(function(r){return r.ok?r.json():{cancelled:true};})
26343 .then(function(d){
26344 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26345 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26346 })
26347 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26348 });
26349 }
26350 var restoreBtn=document.getElementById('restore-btn');
26351 var errBox=document.getElementById('relocate-error-box');
26352 var okBox=document.getElementById('relocate-success-box');
26353 if(restoreBtn){
26354 restoreBtn.addEventListener('click',function(){
26355 var inp=document.getElementById('relocate-folder');
26356 var folder=inp?inp.value.trim():'';
26357 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26358 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26359 var body=new URLSearchParams();
26360 body.set('run_id','{{ run_id }}');
26361 body.set('redirect_url','{{ redirect_url }}');
26362 body.set('folder_path',folder);
26363 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26364 .then(function(r){return r.json();})
26365 .then(function(d){
26366 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26367 if(d&&d.ok){
26368 if(errBox)errBox.classList.add('hidden');
26369 if(okBox){okBox.style.display='block';}
26370 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26371 } else {
26372 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26373 }
26374 })
26375 .catch(function(e){
26376 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26377 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26378 });
26379 });
26380 }
26381 }());
26382 </script>
26383 <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]';
26384 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;}
26385 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>
26386</body>
26387</html>
26388"##,
26389 ext = "html"
26390)]
26391struct RelocateScanTemplate {
26392 message: String,
26393 run_id: String,
26394 folder_hint: String,
26395 redirect_url: String,
26396 server_mode: bool,
26397 csp_nonce: String,
26398 version: &'static str,
26399}
26400
26401#[derive(Template)]
26404#[template(
26405 source = r##"
26406<!doctype html>
26407<html lang="en">
26408<head>
26409 <meta charset="utf-8">
26410 <meta name="viewport" content="width=device-width, initial-scale=1">
26411 <title>OxideSLOC | View Reports</title>
26412 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26413 <style nonce="{{ csp_nonce }}">
26414 :root {
26415 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26416 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26417 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26418 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26419 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26420 }
26421 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; }
26422 *{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;}
26423 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26424 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26425 .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);}
26426 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26427 .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));}
26428 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26429 .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;}
26430 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26431 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26432 @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; } }
26433 .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;}
26434 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26435 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26436 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26437 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26438 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26439 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
26440 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26441 .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);}
26442 .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;}
26443 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26444 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26445 .settings-modal-body{padding:14px 16px 16px;}
26446 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26447 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26448 .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;}
26449 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26450 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26451 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26452 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26453 .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;}
26454 .tz-select:focus{border-color:var(--oxide);}
26455 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26456 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26457 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26458 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26459 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26460 .panel-meta{font-size:13px;color:var(--muted);}
26461 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26462 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26463 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26464 .per-page-label{font-size:13px;color:var(--muted);}
26465 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;}
26466 .filter-input{min-width:180px;cursor:text;}
26467 .table-wrap{width:100%;overflow-x:auto;}
26468 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26469 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;}
26470 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26471 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26472 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26473 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26474 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26475 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26476 tr:last-child td{border-bottom:none;}
26477 tr:hover td{background:var(--surface-2);}
26478 .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);}
26479 .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);}
26480 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26481 .metric-num{font-weight:700;color:var(--text);}
26482 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26483 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26484 .git-commit-chip{cursor:help;}
26485 .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;}
26486 .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;}
26487 .btn:hover{background:var(--line);}
26488 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26489 .btn.primary:hover{opacity:.9;}
26490 .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;}
26491 .btn-back:hover{background:var(--line);}
26492 .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;}
26493 .export-btn:hover{background:var(--line);}
26494 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26495 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26496 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26497 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26498 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26499 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26500 .pagination-info{font-size:13px;color:var(--muted);}
26501 .pagination-btns{display:flex;gap:6px;}
26502 .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;}
26503 .pg-btn:hover:not(:disabled){background:var(--line);}
26504 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26505 .pg-btn:disabled{opacity:.35;cursor:default;}
26506 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26507 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26508 .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);}
26509 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26510 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26511 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26512 .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);}
26513 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26514 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26515 .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;}
26516 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26517 .site-footer a{color:var(--muted);}
26518 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26519 .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%;}
26520 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26521 .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;}
26522 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26523 .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;}
26524 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26525 .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;}
26526 .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;}
26527 .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;}
26528 @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));}}
26529 .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;}
26530 .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;}
26531 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26532 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26533 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26534 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26535 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26536 .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;}
26537 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26538 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26539 .watched-chip-rm:hover{color:var(--oxide);}
26540 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26541 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26542 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26543 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26544 .rpt-btn{min-width:58px;justify-content:center;}
26545 .flex-row{display:flex;align-items:center;gap:8px;}
26546 .report-cell{overflow:visible;white-space:normal;}
26547 #history-table col:nth-child(1){width:185px;}
26548 #history-table col:nth-child(2){width:220px;}
26549 #history-table col:nth-child(3){width:100px;}
26550 #history-table col:nth-child(4){width:72px;}
26551 #history-table col:nth-child(5){width:82px;}
26552 #history-table col:nth-child(6){width:82px;}
26553 #history-table col:nth-child(7){width:65px;}
26554 #history-table col:nth-child(8){width:90px;}
26555 #history-table col:nth-child(9){width:85px;}
26556 #history-table col:nth-child(10){width:115px;}
26557 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26558 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26559 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26560 .submod-details summary::-webkit-details-marker{display:none;}
26561.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26562 .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;}
26563 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26564 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26565 </style>
26566</head>
26567<body>
26568 <div class="background-watermarks" aria-hidden="true">
26569 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26570 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26571 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575 </div>
26576 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26577 <div class="top-nav">
26578 <div class="top-nav-inner">
26579 <a class="brand" href="/">
26580 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26581 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26582 </a>
26583 <div class="nav-right">
26584 <a class="nav-pill" href="/">Home</a>
26585 <div class="nav-dropdown">
26586 <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>
26587 <div class="nav-dropdown-menu">
26588 <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>
26589 </div>
26590 </div>
26591 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26592 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26593 <div class="nav-dropdown">
26594 <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>
26595 <div class="nav-dropdown-menu">
26596 <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>
26597 </div>
26598 </div>
26599 <div class="server-status-wrap" id="server-status-wrap">
26600 <div class="nav-pill server-online-pill" id="server-status-pill">
26601 <span class="status-dot" id="status-dot"></span>
26602 <span id="server-status-label">Server</span>
26603 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26604 </div>
26605 <div class="server-status-tip">
26606 OxideSLOC is running — accessible on your network.
26607 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26608 </div>
26609 </div>
26610 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26611 <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>
26612 </button>
26613 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26614 <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>
26615 <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>
26616 </button>
26617 </div>
26618 </div>
26619 </div>
26620
26621 <div class="page">
26622 {% if let Some(err) = browse_error %}
26623 <div class="toast-error">
26624 <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>
26625 {{ err }}
26626 </div>
26627 {% endif %}
26628 {% if linked_count > 0 %}
26629 <div class="toast-success">
26630 <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>
26631 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26632 </div>
26633 {% endif %}
26634 <div class="watched-bar">
26635 <div class="watched-bar-left">
26636 <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>
26637 <span class="watched-label">Watched Folders</span>
26638 <div class="watched-chips">
26639 {% if server_mode %}
26640 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26641 {% else %}
26642 {% for dir in watched_dirs %}
26643 <span class="watched-chip">
26644 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26645 <form method="POST" action="/watched-dirs/remove" style="display:contents">
26646 <input type="hidden" name="folder_path" value="{{ dir }}">
26647 <input type="hidden" name="redirect_to" value="/view-reports">
26648 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
26649 </form>
26650 </span>
26651 {% endfor %}
26652 {% if watched_dirs.is_empty() %}
26653 <span class="watched-none">No folders watched — click Choose to add one</span>
26654 {% endif %}
26655 {% endif %}
26656 </div>
26657 </div>
26658 {% if !server_mode %}
26659 <div class="watched-bar-right">
26660 <button type="button" class="btn" id="add-watched-btn">
26661 <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>
26662 Choose
26663 </button>
26664 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26665 <input type="hidden" name="redirect_to" value="/view-reports">
26666 <button type="submit" class="btn">↻ Refresh</button>
26667 </form>
26668 </div>
26669 {% endif %}
26670 </div>
26671 {% if total_scans > 0 %}
26672 <div class="summary-strip">
26673 <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>
26674 <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>
26675 <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>
26676 <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>
26677 </div>
26678 {% endif %}
26679
26680 <section class="panel">
26681 <div class="panel-header">
26682 <div>
26683 <h1>View Reports</h1>
26684 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26685 {% 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 %}
26686 </div>
26687 <div class="flex-row">
26688 <button type="button" class="export-btn" id="export-csv-btn">
26689 <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>
26690 Export CSV
26691 </button>
26692 <button type="button" class="export-btn" id="export-xls-btn">
26693 <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>
26694 Export Excel
26695 </button>
26696 </div>
26697 </div>
26698
26699 {% if entries.is_empty() %}
26700 <div class="empty-state">
26701 <strong>No reports with viewable HTML yet</strong>
26702 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.
26703 </div>
26704 {% else %}
26705 <div class="filter-row">
26706 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
26707 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26708 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
26709 </div>
26710 <div class="table-wrap">
26711 <table id="history-table">
26712 <colgroup>
26713 <col><col><col><col><col><col><col><col><col><col>
26714 </colgroup>
26715 <thead>
26716 <tr id="history-thead">
26717 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26718 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26719 <th>Run ID<div class="col-resize-handle"></div></th>
26720 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26721 <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>
26722 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26723 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26724 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26725 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26726 <th>Report<div class="col-resize-handle"></div></th>
26727 </tr>
26728 </thead>
26729 <tbody id="history-tbody">
26730 {% for entry in entries %}
26731 <tr class="history-row" data-run="{{ entry.run_id }}"
26732 data-timestamp="{{ entry.timestamp }}"
26733 data-project="{{ entry.project_label }}"
26734 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26735 data-skipped="{{ entry.files_skipped }}"
26736 data-comments="{{ entry.comment_lines }}"
26737 data-blank="{{ entry.blank_lines }}"
26738 data-physical="{{ entry.total_physical_lines }}"
26739 data-functions="{{ entry.functions }}"
26740 data-classes="{{ entry.classes }}"
26741 data-variables="{{ entry.variables }}"
26742 data-imports="{{ entry.imports }}"
26743 data-tests="{{ entry.test_count }}"
26744 data-branch="{{ entry.git_branch }}"
26745 data-commit="{{ entry.git_commit }}"
26746 data-has-json="{{ entry.has_json }}"
26747 data-html-url="/runs/html/{{ entry.run_id }}">
26748 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26749 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26750 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26751 <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>
26752 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26753 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26754 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26755 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
26756 <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>
26757 <td class="report-cell">
26758 <div class="actions-cell">
26759 {% 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 %}
26760 {% 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 %}
26761 </div>
26762 {% if !entry.submodule_links.is_empty() %}
26763 <details class="submod-details">
26764 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
26765 <div class="submod-link-list">
26766 {% for sub in entry.submodule_links %}
26767 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26768 {% endfor %}
26769 </div>
26770 </details>
26771 {% endif %}
26772 </td>
26773 </tr>
26774 {% endfor %}
26775 </tbody>
26776 </table>
26777 </div>
26778 <div class="pagination">
26779 <span class="pagination-info" id="pagination-info"></span>
26780 <div class="pagination-btns" id="pagination-btns"></div>
26781 <div class="flex-row">
26782 <span class="per-page-label">Show</span>
26783 <select class="per-page" id="per-page-sel">
26784 <option value="10">10 per page</option>
26785 <option value="25" selected>25 per page</option>
26786 <option value="50">50 per page</option>
26787 <option value="100">100 per page</option>
26788 </select>
26789 <span class="per-page-label" id="page-range-label"></span>
26790 </div>
26791 </div>
26792 {% endif %}
26793 </section>
26794 </div>
26795
26796 <footer class="site-footer">
26797 local code analysis - metrics, history and reports
26798 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26799 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26800 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26801 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26802 · <a href="/api-docs" rel="noopener">REST API</a>
26803 </footer>
26804
26805 <script nonce="{{ csp_nonce }}">
26806 (function () {
26807 // ── Theme ──────────────────────────────────────────────────────────────
26808 var storageKey = 'oxide-sloc-theme';
26809 var body = document.body;
26810 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26811 var toggle = document.getElementById('theme-toggle');
26812 if (toggle) toggle.addEventListener('click', function () {
26813 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26814 body.classList.toggle('dark-theme', next === 'dark');
26815 try { localStorage.setItem(storageKey, next); } catch(e) {}
26816 });
26817
26818 // ── State ─────────────────────────────────────────────────────────────
26819 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26820 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26821 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26822
26823 // Aggregate stats from first (most recent) row
26824 if (allRows.length) {
26825 var first = allRows[0];
26826 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();}
26827 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>':'');}
26828 setChipVal('agg-code', first.dataset.code);
26829 setChipVal('agg-files', first.dataset.files);
26830 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26831 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26832 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(); });
26833 }
26834
26835 // ── Branch filter population ──────────────────────────────────────────
26836 (function() {
26837 var branches = {};
26838 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26839 var sel = document.getElementById('branch-filter');
26840 if (sel) Object.keys(branches).sort().forEach(function(b) {
26841 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26842 });
26843 })();
26844
26845 // ── Filter ────────────────────────────────────────────────────────────
26846 function getFilteredRows() {
26847 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26848 var branch = ((document.getElementById('branch-filter') || {}).value || '');
26849 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26850 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26851 if (branch && (r.dataset.branch || '') !== branch) return false;
26852 return true;
26853 });
26854 }
26855
26856 // ── Pagination ────────────────────────────────────────────────────────
26857 function renderPage() {
26858 var filtered = getFilteredRows();
26859 var total = filtered.length;
26860 var totalPages = Math.max(1, Math.ceil(total / perPage));
26861 currentPage = Math.min(currentPage, totalPages);
26862 var start = (currentPage - 1) * perPage;
26863 var end = Math.min(start + perPage, total);
26864 var shown = {};
26865 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26866 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26867 r.style.display = shown[r.dataset.run] ? '' : 'none';
26868 });
26869 var rl = document.getElementById('page-range-label');
26870 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26871 var info = document.getElementById('pagination-info');
26872 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26873 var btns = document.getElementById('pagination-btns');
26874 if (!btns) return;
26875 btns.innerHTML = '';
26876 function makeBtn(lbl, pg, active, disabled) {
26877 var b = document.createElement('button');
26878 b.className = 'pg-btn' + (active ? ' active' : '');
26879 b.textContent = lbl; b.disabled = disabled;
26880 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26881 return b;
26882 }
26883 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26884 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26885 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26886 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26887 }
26888
26889 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26890 window.applyFilters = function() { currentPage = 1; renderPage(); };
26891
26892 // ── Sorting ───────────────────────────────────────────────────────────
26893 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26894 function doSort(col, type, order) {
26895 var tbody = document.getElementById('history-tbody');
26896 if (!tbody) return;
26897 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26898 rows.sort(function(a, b) {
26899 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26900 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26901 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26902 return va < vb ? 1 : va > vb ? -1 : 0;
26903 });
26904 rows.forEach(function(r) { tbody.appendChild(r); });
26905 currentPage = 1; renderPage();
26906 }
26907 sortHeaders.forEach(function(th) {
26908 th.addEventListener('click', function(e) {
26909 if (e.target.classList.contains('col-resize-handle')) return;
26910 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26911 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26912 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26913 th.classList.add('sort-' + sortOrder);
26914 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26915 doSort(col, type, sortOrder);
26916 });
26917 });
26918
26919 // ── Column resize ─────────────────────────────────────────────────────
26920 (function() {
26921 var table = document.getElementById('history-table');
26922 if (!table) return;
26923 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26924 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26925 ths.forEach(function(th, i) {
26926 var handle = th.querySelector('.col-resize-handle');
26927 if (!handle || !cols[i]) return;
26928 var startX, startW;
26929 handle.addEventListener('mousedown', function(e) {
26930 e.stopPropagation(); e.preventDefault();
26931 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26932 handle.classList.add('dragging');
26933 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26934 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26935 document.addEventListener('mousemove', onMove);
26936 document.addEventListener('mouseup', onUp);
26937 });
26938 });
26939 })();
26940
26941 // ── Full-commit hover tooltip ─────────────────────────────────────────
26942 // The commit chips live inside an overflow:auto table wrapper, which would
26943 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26944 // (escaping the scroll container) and follow the cursor. Event delegation
26945 // keeps it working after pagination/sorting re-renders the rows.
26946 (function() {
26947 var tip = document.createElement('div');
26948 tip.className = 'commit-tip';
26949 tip.setAttribute('role', 'tooltip');
26950 document.body.appendChild(tip);
26951 var shown = false;
26952 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26953 function place(e) {
26954 var pad = 14, r = tip.getBoundingClientRect();
26955 var x = e.clientX + pad, y = e.clientY + pad;
26956 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26957 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26958 tip.style.left = x + 'px'; tip.style.top = y + 'px';
26959 }
26960 function hide() { tip.style.display = 'none'; shown = false; }
26961 document.addEventListener('mouseover', function(e) {
26962 var chip = chipFrom(e.target);
26963 if (!chip) return;
26964 var full = chip.getAttribute('data-full-commit');
26965 if (!full) return;
26966 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26967 });
26968 document.addEventListener('mousemove', function(e) {
26969 if (!shown) return;
26970 if (chipFrom(e.target)) place(e); else hide();
26971 });
26972 document.addEventListener('mouseout', function(e) {
26973 if (chipFrom(e.target)) hide();
26974 });
26975 })();
26976
26977 // ── Reset view ────────────────────────────────────────────────────────
26978 window.resetView = function() {
26979 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26980 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26981 sortCol = null; sortOrder = 'asc';
26982 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26983 var tbody = document.getElementById('history-tbody');
26984 if (tbody) {
26985 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26986 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26987 rows.forEach(function(r) { tbody.appendChild(r); });
26988 }
26989 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26990 var table = document.getElementById('history-table');
26991 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26992 currentPage = 1; renderPage();
26993 };
26994
26995 renderPage();
26996
26997 // ── Export helpers ────────────────────────────────────────────────────
26998 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
26999 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27000 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);}
27001 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;');}
27002 function slocXlsx(fname,sheet,hdrs,rows){
27003 var enc=new TextEncoder();
27004 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;}
27005 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;}
27006 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27007 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27008 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27009 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;}
27010 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27011 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];}
27012 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27013 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27014 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27015 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27016 +'<fonts count="2">'
27017 +'<font><sz val="11"/><name val="Calibri"/></font>'
27018 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27019 +'</fonts>'
27020 +'<fills count="3">'
27021 +'<fill><patternFill patternType="none"/></fill>'
27022 +'<fill><patternFill patternType="gray125"/></fill>'
27023 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27024 +'</fills>'
27025 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27026 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27027 +'<cellXfs count="4">'
27028 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27029 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27030 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27031 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27032 +'</cellXfs>'
27033 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27034 +'</styleSheet>';
27035 var rx='<row r="1">';
27036 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27037 rx+='</row>';
27038 rows.forEach(function(row,ri){
27039 var rn=ri+2;rx+='<row r="'+rn+'">';
27040 row.forEach(function(cell,c){
27041 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27042 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27043 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27044 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27045 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27046 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27047 });
27048 rx+='</row>';
27049 });
27050 var lastCol=hdrs.length,lastRow=rows.length+1;
27051 var tableRef='A1:'+colNm(lastCol)+lastRow;
27052 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27053 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27054 +'<autoFilter ref="'+tableRef+'"/>'
27055 +'<tableColumns count="'+lastCol+'">'
27056 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27057 +'</tableColumns>'
27058 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27059 +'</table>';
27060 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27061 +'<Relationships xmlns="'+pns+'relationships">'
27062 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27063 +'</Relationships>';
27064 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>';
27065 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27066 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27067 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27068 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27069 +'</worksheet>';
27070 var F={
27071 '[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>',
27072 '_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>',
27073 '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>',
27074 '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>',
27075 'xl/styles.xml':stl,
27076 'xl/sharedStrings.xml':ssXml,
27077 'xl/worksheets/sheet1.xml':sh,
27078 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27079 'xl/tables/table1.xml':tableXml
27080 };
27081 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'];
27082 var zparts=[],zcds=[],zoff=0,znf=0;
27083 order.forEach(function(name){
27084 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27085 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]);
27086 var entry=new Uint8Array(lha.length+nb.length+sz);
27087 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27088 zparts.push(entry);
27089 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));
27090 var cde=new Uint8Array(cda.length+nb.length);
27091 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27092 zcds.push(cde);zoff+=entry.length;znf++;
27093 });
27094 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27095 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]);
27096 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27097 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27098 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27099 zout.set(new Uint8Array(ea),zpos);
27100 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27101 }
27102
27103 // Multi-sheet XLSX builder for the scan-history export.
27104 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27105 function slocXlsxMulti(fname,sheets){
27106 var enc=new TextEncoder();
27107 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;}
27108 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;}
27109 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27110 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27111 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27112 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];}
27113 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;}
27114 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27115 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27116 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27117 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27118 +'<fonts count="3">'
27119 +'<font><sz val="11"/><name val="Calibri"/></font>'
27120 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27121 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27122 +'</fonts>'
27123 +'<fills count="4">'
27124 +'<fill><patternFill patternType="none"/></fill>'
27125 +'<fill><patternFill patternType="gray125"/></fill>'
27126 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27127 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27128 +'</fills>'
27129 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27130 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27131 +'<cellXfs count="7">'
27132 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27133 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27134 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27135 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27136 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27137 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27138 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27139 +'</cellXfs>'
27140 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27141 +'</styleSheet>';
27142 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27143 sheets.forEach(function(sh,sheetIdx){
27144 var rx='<row r="1">';
27145 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27146 rx+='</row>';
27147 var rn=2;
27148 sh.rows.forEach(function(row){
27149 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27150 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27151 rx+='<row r="'+rn+'">';
27152 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27153 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27154 rx+='</row>';rn++;return;
27155 }
27156 rx+='<row r="'+rn+'">';
27157 row.forEach(function(cell,c){
27158 var ref=colRef(c,rn);
27159 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27160 if(typeof cell==='object'&&cell!==null){
27161 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27162 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27163 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27164 return;
27165 }
27166 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27167 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27168 });
27169 rx+='</row>';rn++;
27170 });
27171 var cw='';
27172 if(sh.colWidths&&sh.colWidths.length>0){
27173 cw='<cols>';
27174 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27175 cw+='</cols>';
27176 }
27177 var tblParts='';
27178 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27179 tableCounter++;
27180 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27181 var tRef='A1:'+colNm(colCount)+rowCount;
27182 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27183 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27184 +'<autoFilter ref="'+tRef+'"/>'
27185 +'<tableColumns count="'+colCount+'">'
27186 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27187 +'</tableColumns>'
27188 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27189 +'</table>';
27190 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27191 +'<Relationships xmlns="'+pns+'relationships">'
27192 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27193 +'</Relationships>';
27194 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27195 }
27196 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27197 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27198 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27199 });
27200 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>';
27201 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27202 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27203 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>';
27204 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27205 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27206 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27207 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27208 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27209 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27210 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};
27211 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27212 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27213 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27214 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27215 var zparts=[],zcds=[],zoff=0,znf=0;
27216 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++;});
27217 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27218 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]);
27219 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27220 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27221 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27222 zout.set(new Uint8Array(ea),zpos);
27223 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27224 }
27225
27226 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'};
27227 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27228
27229 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'];
27230 function getHistoryRows(){
27231 var r=[];
27232 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27233 var code=Number(tr.getAttribute('data-code'))||0;
27234 var phys=Number(tr.getAttribute('data-physical'))||0;
27235 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27236 r.push([
27237 tr.getAttribute('data-timestamp')||'',
27238 tr.getAttribute('data-project')||'',
27239 tr.getAttribute('data-run')||'',
27240 tr.getAttribute('data-physical')||'',
27241 tr.getAttribute('data-code')||'',
27242 tr.getAttribute('data-comments')||'',
27243 tr.getAttribute('data-blank')||'',
27244 tr.getAttribute('data-files')||'',
27245 tr.getAttribute('data-skipped')||'',
27246 tr.getAttribute('data-functions')||'',
27247 tr.getAttribute('data-classes')||'',
27248 tr.getAttribute('data-variables')||'',
27249 tr.getAttribute('data-imports')||'',
27250 tr.getAttribute('data-tests')||'',
27251 dens,
27252 tr.getAttribute('data-branch')||'',
27253 tr.getAttribute('data-commit')||''
27254 ]);
27255 });
27256 return r;
27257 }
27258 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27259 window.exportHistoryXls = function(){
27260 var histRows=getHistoryRows();
27261 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27262 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]];});
27263 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]};
27264 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27265 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27266 var runId=jsonRow.getAttribute('data-run')||'';
27267 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27268 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27269 fetch('/runs/json/'+runId)
27270 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27271 .then(function(run){
27272 var tot=run.summary_totals||{};
27273 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27274 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27275 function B(v){return{v:v,s:4};}
27276 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27277 var sumRows=[
27278 [{_sec:true,v:'RUN INFORMATION'}],
27279 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27280 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27281 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27282 [B('Branch'),run.git_branch||''],
27283 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27284 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27285 [B('Files Analyzed'),N(tot.files_analyzed)],
27286 [B('Files Skipped'),N(tot.files_skipped)],
27287 [],
27288 [{_sec:true,v:'CODE METRICS'}],
27289 [B('Physical Lines'),N(phys)],
27290 [B('Code Lines'),N(code)],
27291 [B('Comments'),N(tot.comment_lines)],
27292 [B('Blank Lines'),N(tot.blank_lines)],
27293 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27294 [B('Functions'),N(tot.functions)],
27295 [B('Classes / Types'),N(tot.classes)],
27296 [B('Variables'),N(tot.variables)],
27297 [B('Imports'),N(tot.imports)],
27298 [B('Tests'),N(tot.test_count)],
27299 [B('Assertions'),N(tot.test_assertion_count)],
27300 [B('Test Suites'),N(tot.test_suite_count)],
27301 [B('Code Density'),{v:dens,s:6}],
27302 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27303 ];
27304 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27305 var langRows=(run.totals_by_language||[]).map(function(l){
27306 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27307 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27308 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];
27309 });
27310 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27311 var pfRows=(run.per_file_records||[]).map(function(r){
27312 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27313 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];
27314 });
27315 var skHdrs=['File','Status','Size (bytes)'];
27316 var skRows=(run.skipped_file_records||[]).map(function(r){
27317 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27318 });
27319 slocXlsxMulti('scan-history.xlsx',[
27320 histSheet,
27321 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27322 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27323 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27324 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27325 ]);
27326 })
27327 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27328 };
27329
27330 var csvBtn = document.getElementById('export-csv-btn');
27331 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27332 var xlsBtn = document.getElementById('export-xls-btn');
27333 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27334
27335 // ── Remaining CSP-safe event bindings ────────────────────────────────
27336 (function wireEvents() {
27337 var el;
27338 el = document.getElementById('reset-view-btn');
27339 if (el) el.addEventListener('click', window.resetView);
27340 el = document.getElementById('project-filter');
27341 if (el) el.addEventListener('input', window.applyFilters);
27342 el = document.getElementById('branch-filter');
27343 if (el) el.addEventListener('change', window.applyFilters);
27344 el = document.getElementById('per-page-sel');
27345 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27346 el = document.getElementById('add-watched-btn');
27347 if (el) el.addEventListener('click', function() {
27348 fetch('/pick-directory?kind=reports')
27349 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27350 .then(function(data) {
27351 if (!data.cancelled && data.selected_path) {
27352 var form = document.createElement('form');
27353 form.method = 'POST';
27354 form.action = '/watched-dirs/add';
27355 var ri = document.createElement('input');
27356 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27357 var fi = document.createElement('input');
27358 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27359 form.appendChild(ri); form.appendChild(fi);
27360 document.body.appendChild(form);
27361 form.submit();
27362 }
27363 })
27364 .catch(function(e) { alert('Could not open folder picker: ' + e); });
27365 });
27366 })();
27367
27368 (function randomizeWatermarks() {
27369 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27370 if (!wms.length) return;
27371 var placed = [];
27372 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;}
27373 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];}
27374 var half=Math.floor(wms.length/2);
27375 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;});
27376 })();
27377
27378 (function spawnCodeParticles() {
27379 var container = document.getElementById('code-particles');
27380 if (!container) return;
27381 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'];
27382 for (var i = 0; i < 38; i++) {
27383 (function(idx) {
27384 var el = document.createElement('span');
27385 el.className = 'code-particle';
27386 el.textContent = snippets[idx % snippets.length];
27387 var left = Math.random() * 94 + 2;
27388 var top = Math.random() * 88 + 6;
27389 var dur = (Math.random() * 10 + 9).toFixed(1);
27390 var delay = (Math.random() * 18).toFixed(1);
27391 var rot = (Math.random() * 26 - 13).toFixed(1);
27392 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27393 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';
27394 container.appendChild(el);
27395 })(i);
27396 }
27397 })();
27398 })();
27399 </script>
27400 <script nonce="{{ csp_nonce }}">
27401 (function(){
27402 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'}];
27403 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);});}
27404 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27405 function init(){
27406 var btn=document.getElementById('settings-btn');if(!btn)return;
27407 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27408 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>';
27409 document.body.appendChild(m);
27410 var g=document.getElementById('scheme-grid');
27411 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);});
27412 var cl=document.getElementById('settings-close');
27413 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
27414 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');});
27415 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27416 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27417 }
27418 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27419 }());
27420 </script>
27421 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';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>
27422</body>
27423</html>
27424"##,
27425 ext = "html"
27426)]
27427struct HistoryTemplate {
27428 version: &'static str,
27429 entries: Vec<HistoryEntryRow>,
27430 total_scans: usize,
27431 linked_count: usize,
27432 browse_error: Option<String>,
27433 watched_dirs: Vec<String>,
27434 csp_nonce: String,
27435 server_mode: bool,
27436}
27437
27438#[derive(Template)]
27441#[template(
27442 source = r##"
27443<!doctype html>
27444<html lang="en">
27445<head>
27446 <meta charset="utf-8">
27447 <meta name="viewport" content="width=device-width, initial-scale=1">
27448 <title>OxideSLOC | Compare Scans</title>
27449 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27450 <style nonce="{{ csp_nonce }}">
27451 :root {
27452 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27453 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27454 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27455 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27456 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27457 }
27458 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27459 *{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;}
27460 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27461 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27462 .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);}
27463 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27464 .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));}
27465 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27466 .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;}
27467 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27468 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27469 @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; } }
27470 .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;}
27471 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27472 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27473 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27474 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27475 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27476 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
27477 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27478 .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);}
27479 .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;}
27480 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27481 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27482 .settings-modal-body{padding:14px 16px 16px;}
27483 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27484 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27485 .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;}
27486 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27487 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27488 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27489 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27490 .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;}
27491 .tz-select:focus{border-color:var(--oxide);}
27492 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27493 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27494 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27495 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27496 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27497 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27498 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27499 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27500 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27501 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27502 .per-page-label{font-size:13px;color:var(--muted);}
27503 select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
27504 .filter-input{min-width:180px;cursor:text;}
27505 .table-wrap{width:100%;overflow-x:auto;}
27506 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27507 th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
27508 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27509 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27510 #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;}
27511 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27512 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27513 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27514 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27515 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27516 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27517 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27518 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27519 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27520 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27521 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27522 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27523 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27524 tr:last-child td{border-bottom:none;}
27525 tr.selected td{background:var(--sel-bg);}
27526 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27527 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27528 tr{cursor:pointer;}
27529 tr.row-locked{opacity:.35;cursor:not-allowed;}
27530 tr.row-locked td{pointer-events:none;}
27531 .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;}
27532 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27533 .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;}
27534 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27535 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27536 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27537 .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);}
27538 .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);}
27539 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27540 .metric-num{font-weight:700;color:var(--text);}
27541 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27542 .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;}
27543 .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;}
27544 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27545 .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;}
27546 .btn:hover{background:var(--line);}
27547 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27548 .btn.primary:hover{opacity:.9;}
27549 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27550 .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;}
27551 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27552 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27553 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27554 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27555 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27556 .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;}
27557 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27558 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27559 .watched-chip-rm:hover{color:var(--oxide);}
27560 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27561 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27562 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27563 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27564 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27565 .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;}
27566 .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;}
27567 .btn-back:hover{background:var(--line);}
27568 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27569 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27570 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27571 .pagination-info{font-size:13px;color:var(--muted);}
27572 .pagination-btns{display:flex;gap:6px;}
27573 .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;}
27574 .pg-btn:hover:not(:disabled){background:var(--line);}
27575 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27576 .pg-btn:disabled{opacity:.35;cursor:default;}
27577 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27578 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27579 .site-footer a{color:var(--muted);}
27580 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27581 .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;}
27582 .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;}
27583 .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;}
27584 @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));}}
27585 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27586 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27587 .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);}
27588 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27589 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27590 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27591 .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);}
27592 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27593 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27594 .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;}
27595 .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;}
27596 .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%;}
27597 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27598 .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;}
27599 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27600 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27601 .hidden{display:none!important;}
27602 .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%;}
27603 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27604 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27605 .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;}
27606 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27607 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27608 .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;}
27609 .scope-option:hover{background:var(--line);}
27610 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27611 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27612 .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;}
27613 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27614 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27615 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27616 .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;}
27617 </style>
27618</head>
27619<body>
27620 <div class="background-watermarks" aria-hidden="true">
27621 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27622 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27623 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27624 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27625 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27626 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27627 </div>
27628 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27629 <div class="top-nav">
27630 <div class="top-nav-inner">
27631 <a class="brand" href="/">
27632 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27633 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27634 </a>
27635 <div class="nav-right">
27636 <a class="nav-pill" href="/">Home</a>
27637 <div class="nav-dropdown">
27638 <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>
27639 <div class="nav-dropdown-menu">
27640 <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>
27641 </div>
27642 </div>
27643 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27644 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27645 <div class="nav-dropdown">
27646 <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>
27647 <div class="nav-dropdown-menu">
27648 <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>
27649 </div>
27650 </div>
27651 <div class="server-status-wrap" id="server-status-wrap">
27652 <div class="nav-pill server-online-pill" id="server-status-pill">
27653 <span class="status-dot" id="status-dot"></span>
27654 <span id="server-status-label">Server</span>
27655 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27656 </div>
27657 <div class="server-status-tip">
27658 OxideSLOC is running — accessible on your network.
27659 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27660 </div>
27661 </div>
27662 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27663 <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>
27664 </button>
27665 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27666 <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>
27667 <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>
27668 </button>
27669 </div>
27670 </div>
27671 </div>
27672
27673 <div class="page">
27674 <div class="watched-bar">
27675 <div class="watched-bar-left">
27676 <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>
27677 <span class="watched-label">Watched Folders</span>
27678 <div class="watched-chips">
27679 {% if server_mode %}
27680 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27681 {% else %}
27682 {% for dir in watched_dirs %}
27683 <span class="watched-chip">
27684 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27685 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27686 <input type="hidden" name="folder_path" value="{{ dir }}">
27687 <input type="hidden" name="redirect_to" value="/compare-scans">
27688 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27689 </form>
27690 </span>
27691 {% endfor %}
27692 {% if watched_dirs.is_empty() %}
27693 <span class="watched-none">No folders watched — click Choose to add one</span>
27694 {% endif %}
27695 {% endif %}
27696 </div>
27697 </div>
27698 {% if !server_mode %}
27699 <div class="watched-bar-right">
27700 <button type="button" class="btn" id="add-watched-btn">
27701 <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>
27702 Choose
27703 </button>
27704 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27705 <input type="hidden" name="redirect_to" value="/compare-scans">
27706 <button type="submit" class="btn">↻ Refresh</button>
27707 </form>
27708 </div>
27709 {% endif %}
27710 </div>
27711 {% if total_scans > 0 %}
27712 <div class="summary-strip">
27713 <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>
27714 <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>
27715 <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>
27716 <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>
27717 </div>
27718 {% endif %}
27719 <section class="panel">
27720 <div class="panel-header">
27721 <div>
27722 <h1>Compare Scans</h1>
27723 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27724 </div>
27725 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27726 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27727 <button class="btn primary" id="compare-btn" disabled>
27728 <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>
27729 Compare <span class="sel-count" id="sel-count">0</span> Selected
27730 </button>
27731 </div>
27732 </div>
27733 </div>
27734
27735 {% if entries.is_empty() %}
27736 <div class="empty-state">
27737 <strong>No scans yet</strong>
27738 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.
27739 </div>
27740 {% else %}
27741 <div class="filter-row">
27742 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27743 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27744 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27745 </div>
27746 <div class="scope-panel hidden" id="scope-panel">
27747 <div class="scope-panel-label">
27748 <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>
27749 Compare scope — choose what to include
27750 </div>
27751 <div class="scope-options" id="scope-options"></div>
27752 </div>
27753 {% if total_scans > 0 %}
27754 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27755 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27756 <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>
27757 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27758 </div>
27759 </div>
27760 {% endif %}
27761 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27762 <span class="compare-all-label">
27763 <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>
27764 Quick Compare All
27765 </span>
27766 </div>
27767 <div class="table-wrap">
27768 <table id="compare-table">
27769 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27770 <thead>
27771 <tr id="compare-thead">
27772 <th><div class="col-resize-handle"></div></th>
27773 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27774 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27775 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27776 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27777 <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>
27778 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27779 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27780 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27781 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27782 <th>Submodules<div class="col-resize-handle"></div></th>
27783 </tr>
27784 </thead>
27785 <tbody id="compare-tbody">
27786 {% for entry in entries %}
27787 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27788 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27789 data-project="{{ entry.project_label }}"
27790 data-files="{{ entry.files_analyzed }}"
27791 data-code="{{ entry.code_lines }}"
27792 data-comments="{{ entry.comment_lines }}"
27793 data-blank="{{ entry.blank_lines }}"
27794 data-branch="{{ entry.git_branch }}"
27795 data-commit="{{ entry.git_commit }}"
27796 data-submodules="{{ entry.submodule_names_csv }}">
27797 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27798 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27799 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27800 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27801 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27802 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27803 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27804 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27805 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
27806 <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>
27807 <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>
27808 </tr>
27809 {% endfor %}
27810 </tbody>
27811 </table>
27812 </div>
27813 <div class="pagination">
27814 <span class="pagination-info" id="pagination-info"></span>
27815 <div class="pagination-btns" id="pagination-btns"></div>
27816 <div class="flex-row">
27817 <span class="per-page-label">Show</span>
27818 <select class="per-page" id="per-page-sel">
27819 <option value="10">10 per page</option>
27820 <option value="25" selected>25 per page</option>
27821 <option value="50">50 per page</option>
27822 <option value="100">100 per page</option>
27823 </select>
27824 <span class="per-page-label" id="page-range-label"></span>
27825 </div>
27826 </div>
27827 {% endif %}
27828 </section>
27829 </div>
27830
27831 <footer class="site-footer">
27832 local code analysis - metrics, history and reports
27833 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27834 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27835 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27836 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27837 · <a href="/api-docs" rel="noopener">REST API</a>
27838 </footer>
27839
27840 <script nonce="{{ csp_nonce }}">
27841 (function () {
27842 // ── Theme ──────────────────────────────────────────────────────────────
27843 var storageKey = 'oxide-sloc-theme';
27844 var body = document.body;
27845 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27846 var toggle = document.getElementById('theme-toggle');
27847 if (toggle) toggle.addEventListener('click', function () {
27848 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27849 body.classList.toggle('dark-theme', next === 'dark');
27850 try { localStorage.setItem(storageKey, next); } catch(e) {}
27851 });
27852
27853 // ── State ─────────────────────────────────────────────────────────────
27854 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27855 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27856 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27857 window._allCompareRows = allRows;
27858
27859 // ── Stat chips ────────────────────────────────────────────────────────
27860 (function() {
27861 var projects = {}, latestTs = '', latestRow = null;
27862 allRows.forEach(function(r) {
27863 var p = r.dataset.project || ''; if (p) projects[p] = true;
27864 var ts = r.dataset.timestamp || '';
27865 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27866 });
27867 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();}
27868 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>':'');}
27869 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27870 if (latestRow) {
27871 setChipVal('agg-code', latestRow.dataset.code);
27872 setChipVal('agg-files', latestRow.dataset.files);
27873 }
27874 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(); });
27875 })();
27876
27877 // ── Branch filter population ──────────────────────────────────────────
27878 (function() {
27879 var branches = {};
27880 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27881 var sel = document.getElementById('branch-filter');
27882 if (sel) Object.keys(branches).sort().forEach(function(b) {
27883 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27884 });
27885 })();
27886
27887 // ── Filter ────────────────────────────────────────────────────────────
27888 function getFilteredRows() {
27889 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27890 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27891 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27892 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27893 if (branch && (r.dataset.branch || '') !== branch) return false;
27894 return true;
27895 });
27896 }
27897
27898 // ── Pagination ────────────────────────────────────────────────────────
27899 function renderPage() {
27900 var filtered = getFilteredRows();
27901 var total = filtered.length;
27902 var totalPages = Math.max(1, Math.ceil(total / perPage));
27903 currentPage = Math.min(currentPage, totalPages);
27904 var start = (currentPage - 1) * perPage;
27905 var end = Math.min(start + perPage, total);
27906 var shown = {};
27907 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27908 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27909 r.style.display = shown[r.dataset.run] ? '' : 'none';
27910 });
27911 var rl = document.getElementById('page-range-label');
27912 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27913 var info = document.getElementById('pagination-info');
27914 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27915 var btns = document.getElementById('pagination-btns');
27916 if (!btns) return;
27917 btns.innerHTML = '';
27918 function makeBtn(lbl, pg, active, disabled) {
27919 var b = document.createElement('button');
27920 b.className = 'pg-btn' + (active ? ' active' : '');
27921 b.textContent = lbl; b.disabled = disabled;
27922 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27923 return b;
27924 }
27925 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27926 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27927 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27928 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27929 }
27930
27931 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27932 window.applyFilters = function() { currentPage = 1; renderPage(); };
27933
27934 // ── Sorting ───────────────────────────────────────────────────────────
27935 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27936 function doSort(col, type, order) {
27937 var tbody = document.getElementById('compare-tbody');
27938 if (!tbody) return;
27939 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27940 rows.sort(function(a, b) {
27941 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27942 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27943 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27944 return va < vb ? 1 : va > vb ? -1 : 0;
27945 });
27946 rows.forEach(function(r) { tbody.appendChild(r); });
27947 currentPage = 1; renderPage();
27948 }
27949 sortHeaders.forEach(function(th) {
27950 th.addEventListener('click', function(e) {
27951 if (e.target.classList.contains('col-resize-handle')) return;
27952 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27953 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27954 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27955 th.classList.add('sort-' + sortOrder);
27956 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27957 doSort(col, type, sortOrder);
27958 });
27959 });
27960
27961 // Apply default sort (timestamp desc) on initial load
27962 (function() {
27963 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27964 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27965 })();
27966
27967 // ── Column resize ─────────────────────────────────────────────────────
27968 (function() {
27969 var table = document.getElementById('compare-table');
27970 if (!table) return;
27971 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27972 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27973 ths.forEach(function(th, i) {
27974 var handle = th.querySelector('.col-resize-handle');
27975 if (!handle || !cols[i]) return;
27976 var startX, startW;
27977 handle.addEventListener('mousedown', function(e) {
27978 e.stopPropagation(); e.preventDefault();
27979 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27980 handle.classList.add('dragging');
27981 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27982 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27983 document.addEventListener('mousemove', onMove);
27984 document.addEventListener('mouseup', onUp);
27985 });
27986 });
27987 })();
27988
27989 // ── Full-commit hover tooltip ─────────────────────────────────────────
27990 // The commit chips live inside an overflow:auto table wrapper, which would
27991 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27992 // (escaping the scroll container) and follow the cursor. Event delegation
27993 // keeps it working after pagination/sorting re-renders the rows.
27994 (function() {
27995 var tip = document.createElement('div');
27996 tip.className = 'commit-tip';
27997 tip.setAttribute('role', 'tooltip');
27998 document.body.appendChild(tip);
27999 var shown = false;
28000 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28001 function place(e) {
28002 var pad = 14, r = tip.getBoundingClientRect();
28003 var x = e.clientX + pad, y = e.clientY + pad;
28004 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28005 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28006 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28007 }
28008 function hide() { tip.style.display = 'none'; shown = false; }
28009 document.addEventListener('mouseover', function(e) {
28010 var chip = chipFrom(e.target);
28011 if (!chip) return;
28012 var full = chip.getAttribute('data-full-commit');
28013 if (!full) return;
28014 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28015 });
28016 document.addEventListener('mousemove', function(e) {
28017 if (!shown) return;
28018 if (chipFrom(e.target)) place(e); else hide();
28019 });
28020 document.addEventListener('mouseout', function(e) {
28021 if (chipFrom(e.target)) hide();
28022 });
28023 })();
28024
28025 // ── Reset view ────────────────────────────────────────────────────────
28026 window.resetView = function() {
28027 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28028 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28029 sortCol = null; sortOrder = 'asc';
28030 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28031 var tbody = document.getElementById('compare-tbody');
28032 if (tbody) {
28033 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28034 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28035 rows.forEach(function(r) { tbody.appendChild(r); });
28036 }
28037 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28038 var table = document.getElementById('compare-table');
28039 currentPage = 1; renderPage();
28040 currentPage = 1; renderPage();
28041 };
28042
28043 renderPage();
28044 buildCompareAllBar();
28045
28046 // ── Row selection state ───────────────────────────────────────────────
28047 var selected = [];
28048 var lockedProject = null; // project label of first selected scan
28049
28050 function updateCompareBtn() {
28051 var btn = document.getElementById('compare-btn');
28052 var cnt = document.getElementById('sel-count');
28053 if (!btn) return;
28054 btn.disabled = selected.length < 2;
28055 if (cnt) cnt.textContent = selected.length;
28056 }
28057
28058 function applyProjectLock() {
28059 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28060 allRows.forEach(function(r) {
28061 if (lockedProject === null) {
28062 r.classList.remove('row-locked');
28063 } else {
28064 var proj = r.dataset.project || '';
28065 if (proj !== lockedProject) {
28066 r.classList.add('row-locked');
28067 } else {
28068 r.classList.remove('row-locked');
28069 }
28070 }
28071 });
28072 }
28073
28074 function toggleRow(row) {
28075 if (row.classList.contains('row-locked')) return;
28076 var vid = row.dataset.vid || row.dataset.run;
28077 var idx = selected.indexOf(vid);
28078 if (idx >= 0) {
28079 selected.splice(idx, 1);
28080 row.classList.remove('selected');
28081 var b = document.getElementById('badge-' + vid);
28082 if (b) b.textContent = '';
28083 // Release project lock if nothing selected
28084 if (selected.length === 0) lockedProject = null;
28085 } else {
28086 // Set project lock on first selection
28087 if (selected.length === 0) lockedProject = row.dataset.project || null;
28088 selected.push(vid);
28089 row.classList.add('selected');
28090 }
28091 selected.forEach(function(v, i) {
28092 var b = document.getElementById('badge-' + v);
28093 if (b) b.textContent = i + 1;
28094 });
28095 applyProjectLock();
28096 updateCompareBtn();
28097 buildScopePanel();
28098 }
28099
28100 // ── Compare-All bar ───────────────────────────────────────────────────
28101 function buildCompareAllBar() {
28102 var bar = document.getElementById('compare-all-bar');
28103 if (!bar) return;
28104 // Group all rows by project label.
28105 var groups = {};
28106 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28107 // Use all rows from the source data (not just visible).
28108 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28109 // We need ALL rows across all pages, not just the rendered ones.
28110 // Use the underlying allRows array that the pagination JS also uses.
28111 var sourceRows = window._allCompareRows || allRowsAll;
28112 sourceRows.forEach(function(r) {
28113 var proj = r.dataset.project || '';
28114 var vid = r.dataset.vid || r.dataset.run || '';
28115 if (!proj || !vid) return;
28116 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28117 groups[proj].ids.push(vid);
28118 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28119 });
28120 // Build buttons for each project with >= 2 scans.
28121 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28122 if (!keys.length) { bar.style.display = 'none'; return; }
28123 bar.style.display = 'flex';
28124 // Remove old buttons (keep label).
28125 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28126 oldBtns.forEach(function(b) { b.remove(); });
28127 keys.sort();
28128 keys.forEach(function(proj) {
28129 var g = groups[proj];
28130 var btn = document.createElement('button');
28131 btn.className = 'compare-all-btn';
28132 btn.type = 'button';
28133 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28134 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28135 btn.addEventListener('click', function() {
28136 // Sort ids by timestamp (ascending).
28137 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28138 pairs.sort(function(a, b) { return a.ts - b.ts; });
28139 var sorted = pairs.map(function(p) { return p.id; });
28140 if (sorted.length === 2) {
28141 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28142 } else {
28143 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28144 }
28145 });
28146 bar.appendChild(btn);
28147 });
28148 }
28149
28150 // ── Scope panel ───────────────────────────────────────────────────────
28151 var selectedScope = 'all';
28152
28153 function buildScopePanel() {
28154 var panel = document.getElementById('scope-panel');
28155 var opts = document.getElementById('scope-options');
28156 if (!panel || !opts) return;
28157 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28158
28159 // Collect union of submodules from all selected rows.
28160 var allSubs = {};
28161 selected.forEach(function(vid) {
28162 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28163 if (!row) return;
28164 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28165 });
28166 var subList = Object.keys(allSubs).sort();
28167 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28168
28169 panel.classList.remove('hidden');
28170 opts.innerHTML = '';
28171
28172 function makeOption(value, label, title) {
28173 var div = document.createElement('div');
28174 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28175 div.dataset.scopeValue = value;
28176 if (title) div.title = title;
28177 var radio = document.createElement('span');
28178 radio.className = 'scope-option-radio';
28179 var lbl = document.createElement('span');
28180 lbl.textContent = label;
28181 div.appendChild(radio);
28182 div.appendChild(lbl);
28183 div.addEventListener('click', function() {
28184 selectedScope = value;
28185 opts.querySelectorAll('.scope-option').forEach(function(o) {
28186 o.classList.toggle('selected', o.dataset.scopeValue === value);
28187 });
28188 });
28189 return div;
28190 }
28191
28192 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28193 var sep = document.createElement('span');
28194 sep.className = 'scope-option-sep';
28195 opts.appendChild(sep);
28196 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28197 subList.forEach(function(s) {
28198 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28199 });
28200 }
28201
28202 function doCompare() {
28203 if (selected.length < 2) return;
28204 if (selected.length === 2) {
28205 // Two-scan delta (existing flow with scope support).
28206 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28207 if (selectedScope === 'super') url += '&scope=super';
28208 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28209 window.location.href = url;
28210 } else {
28211 // Multi-scan timeline (N >= 3) — pass scope params too.
28212 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28213 if (selectedScope === 'super') url += '&scope=super';
28214 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28215 window.location.href = url;
28216 }
28217 }
28218
28219 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28220 var cbtn = document.getElementById('compare-btn');
28221 if (cbtn) cbtn.addEventListener('click', doCompare);
28222 var pfEl = document.getElementById('project-filter');
28223 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28224 var bfEl = document.getElementById('branch-filter');
28225 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28226 var rvBtn = document.getElementById('reset-view-btn');
28227 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28228 var ppSel = document.getElementById('per-page-sel');
28229 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28230
28231 var cmpTbody = document.getElementById('compare-tbody');
28232 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28233 var row = e.target.closest('.compare-row');
28234 if (row) toggleRow(row);
28235 });
28236
28237 (function randomizeWatermarks() {
28238 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28239 if (!wms.length) return;
28240 var placed = [];
28241 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;}
28242 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];}
28243 var half=Math.floor(wms.length/2);
28244 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;});
28245 })();
28246
28247 (function spawnCodeParticles() {
28248 var container = document.getElementById('code-particles');
28249 if (!container) return;
28250 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'];
28251 for (var i = 0; i < 38; i++) {
28252 (function(idx) {
28253 var el = document.createElement('span');
28254 el.className = 'code-particle';
28255 el.textContent = snippets[idx % snippets.length];
28256 var left = Math.random() * 94 + 2;
28257 var top = Math.random() * 88 + 6;
28258 var dur = (Math.random() * 10 + 9).toFixed(1);
28259 var delay = (Math.random() * 18).toFixed(1);
28260 var rot = (Math.random() * 26 - 13).toFixed(1);
28261 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28262 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';
28263 container.appendChild(el);
28264 })(i);
28265 }
28266 })();
28267
28268 // ── Watched folder picker ─────────────────────────────────────────────
28269 (function() {
28270 var btn = document.getElementById('add-watched-btn');
28271 if (!btn) return;
28272 btn.addEventListener('click', function() {
28273 fetch('/pick-directory?kind=reports')
28274 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28275 .then(function(data) {
28276 if (!data.cancelled && data.selected_path) {
28277 var form = document.createElement('form');
28278 form.method = 'POST';
28279 form.action = '/watched-dirs/add';
28280 var ri = document.createElement('input');
28281 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28282 var fi = document.createElement('input');
28283 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28284 form.appendChild(ri); form.appendChild(fi);
28285 document.body.appendChild(form);
28286 form.submit();
28287 }
28288 })
28289 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28290 });
28291 })();
28292
28293 // ── Submodule chip truncation ─────────────────────────────────────────
28294 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28295 var chips = cell.querySelectorAll('.submod-chip');
28296 var MAX = 4;
28297 if (chips.length <= MAX) return;
28298 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28299 var badge = document.createElement('span');
28300 badge.className = 'submod-overflow-badge';
28301 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28302 badge.textContent = '+' + (chips.length - MAX) + ' more';
28303 cell.appendChild(badge);
28304 cell.style.maxHeight = 'none';
28305 });
28306 })();
28307 </script>
28308 <script nonce="{{ csp_nonce }}">
28309 (function(){
28310 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'}];
28311 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);});}
28312 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28313 function init(){
28314 var btn=document.getElementById('settings-btn');if(!btn)return;
28315 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28316 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>';
28317 document.body.appendChild(m);
28318 var g=document.getElementById('scheme-grid');
28319 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);});
28320 var cl=document.getElementById('settings-close');
28321 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
28322 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');});
28323 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28324 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28325 }
28326 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28327 }());
28328 </script>
28329 <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]';
28330 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;}
28331 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>
28332</body>
28333</html>
28334"##,
28335 ext = "html"
28336)]
28337struct CompareSelectTemplate {
28338 version: &'static str,
28339 entries: Vec<HistoryEntryRow>,
28340 total_scans: usize,
28341 watched_dirs: Vec<String>,
28342 csp_nonce: String,
28343 server_mode: bool,
28344}
28345
28346#[derive(Template)]
28349#[template(
28350 source = r##"
28351<!doctype html>
28352<html lang="en">
28353<head>
28354 <meta charset="utf-8">
28355 <meta name="viewport" content="width=device-width, initial-scale=1">
28356 <title>OxideSLOC | Scan Delta</title>
28357 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28358 <style nonce="{{ csp_nonce }}">
28359 :root {
28360 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28361 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28362 --nav:#283790; --nav-2:#013e6b;
28363 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28364 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28365 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28366 }
28367 body.dark-theme {
28368 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28369 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28370 }
28371 *{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;}
28372 .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);}
28373 .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;}
28374 .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));}
28375 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28376 .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;}
28377 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28378 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28379 @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; } }
28380 .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;}
28381 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28382 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28383 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28384 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28385 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
28386 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28387 .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);}
28388 .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;}
28389 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28390 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28391 .settings-modal-body{padding:14px 16px 16px;}
28392 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28393 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28394 .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;}
28395 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28396 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28397 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28398 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28399 .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;}
28400 .tz-select:focus{border-color:var(--oxide);}
28401 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28402 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28403 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28404 .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;}
28405 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28406 .hero-body{display:block;}
28407 .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;}
28408 .btn-back:hover{background:var(--line);}
28409 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28410 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28411 .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;}
28412 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28413 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;}
28414 .muted{color:var(--muted);font-size:14px;}
28415 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28416 .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;}
28417 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28418 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28419 .vpill-arrow{font-size:20px;color:var(--muted);}
28420 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28421 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28422 .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;}
28423 .delta-card.delta-card-wide{padding:22px 24px;}
28424 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28425 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28426 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28427 .delta-card-from{font-size:15px;color:var(--muted);}
28428 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28429 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28430 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28431 .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%;}
28432 .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;}
28433 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28434 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28435 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28436 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28437 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28438 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28439 .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;}
28440 .meta-card-commit:hover{color:var(--oxide);}
28441 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28442 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28443 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28444 .meta-value{color:var(--text);font-size:13px;}
28445 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28446 .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;}
28447 .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);}
28448 .delta-card:hover .dc-tip{display:block;}
28449 .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;}
28450 .export-btn:hover{background:var(--line);}
28451 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28452 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28453 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28454 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28455 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28456 .delta-card-change.zero{color:var(--muted);background:transparent;}
28457 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28458 .delta-card-pct.pos{color:var(--pos);}
28459 .delta-card-pct.neg{color:var(--neg);}
28460 .delta-card-pct.zero{color:var(--muted);}
28461 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28462 .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;}
28463 .insight-card.insight-flag{border-color:var(--oxide);}
28464 .insight-card:hover .dc-tip{display:block;}
28465 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28466 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28467 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28468 .insight-label.flag{color:var(--oxide);}
28469 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28470 .insight-val.pos{color:var(--pos);}
28471 .insight-val.neg{color:var(--neg);}
28472 .insight-val.high{color:#c0392a;}
28473 .insight-val.med{color:#926000;}
28474 .insight-val.low{color:var(--pos);}
28475 body.dark-theme .insight-val.high{color:#ff6b6b;}
28476 body.dark-theme .insight-val.med{color:#f0c060;}
28477 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28478 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28479 .fc-row{display:flex;align-items:center;gap:8px;}
28480 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28481 .fc-label{color:var(--muted);}
28482 .fc-modified .fc-count{color:#926000;}
28483 .fc-added .fc-count{color:var(--pos);}
28484 .fc-removed .fc-count{color:var(--neg);}
28485 .fc-unchanged .fc-count{color:var(--muted);}
28486 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28487 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28488 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28489 .chip.modified{background:#fff2d8;color:#926000;}
28490 .chip.added{background:#e8f5ed;color:#1a8f47;}
28491 .chip.removed{background:#fdeaea;color:#b33b3b;}
28492 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28493 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28494 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28495 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28496 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28497 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28498 .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;}
28499 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28500 .tab-btn:hover:not(.active){background:var(--line);}
28501 .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;}
28502 .btn-reset:hover{background:var(--line);}
28503 .table-wrap{width:100%;overflow-x:auto;}
28504 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28505 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);}
28506 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28507 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28508 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28509 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28510 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28511 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28512 tr:last-child td{border-bottom:none;}
28513 tr:hover td{background:var(--surface-2);}
28514 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28515 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28516 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28517 /* Fixed layout: column widths come from the colgroup, not from scanning every
28518 row. With auto layout a large file matrix forces the browser to re-measure
28519 all cells on each reflow, which freezes the page during sort/resize. */
28520 #delta-table{table-layout:fixed;}
28521 #delta-table col:nth-child(1){width:32%;}
28522 #delta-table col:nth-child(2){width:11%;}
28523 #delta-table col:nth-child(3){width:11%;}
28524 #delta-table col:nth-child(4){width:16%;}
28525 #delta-table col:nth-child(5){width:10%;}
28526 #delta-table col:nth-child(6){width:10%;}
28527 #delta-table col:nth-child(7){width:10%;}
28528 tr.row-added td{background:rgba(26,143,71,0.04);}
28529 tr.row-removed td{background:rgba(179,59,59,0.06);}
28530 tr.row-modified td{background:rgba(146,96,0,0.04);}
28531 tr.row-unchanged td{color:var(--muted);}
28532 tr.row-unchanged .status-badge{opacity:.65;}
28533 .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;}
28534 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28535 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28536 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28537 .status-badge.modified{background:#fff2d8;color:#926000;}
28538 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28539 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28540 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28541 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28542 .delta-val{font-weight:700;}
28543 .delta-val.pos{color:var(--pos);}
28544 .delta-val.neg{color:var(--neg);}
28545 .delta-val.zero{color:var(--muted);}
28546 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28547 .from-to strong{color:var(--text);font-weight:700;}
28548 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28549 .from-to .ft-absent{color:var(--muted);font-weight:600;}
28550 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28551 .site-footer a{color:var(--muted);}
28552 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;}
28553 body.pdf-mode{background:#fff!important;}
28554 body.pdf-mode .page{padding:4px 6px 4px!important;}
28555 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28556 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28557 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28558 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28559 .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;}
28560 .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;}
28561 .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;}
28562 @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));}}
28563 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28564 .path-link:hover{color:var(--oxide-2);}
28565 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28566 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28567 a.vpill-id:hover{color:var(--oxide);}
28568 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28569 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28570 .pagination-info{font-size:13px;color:var(--muted);}
28571 .pagination-btns{display:flex;gap:6px;}
28572 .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;}
28573 .pg-btn:hover:not(:disabled){background:var(--line);}
28574 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28575 .pg-btn:disabled{opacity:.35;cursor:default;}
28576 .per-page-label{font-size:13px;color:var(--muted);}
28577 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;}
28578 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28579 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28580 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28581 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28582 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28583 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28584 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28585 .tab-btn.tab-unchanged{color:var(--muted);}
28586 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28587 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28588 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28589 .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;}
28590 .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;}
28591 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28592 .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;}
28593 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28594 .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;}
28595 .submod-scope-btn:hover{background:var(--line);}
28596 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28597 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28598 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28599 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28600 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28601 body.dark-theme .ic-card{background:var(--surface-2);}
28602 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28603 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28604 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28605 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28606 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28607 .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);}
28608 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28609 .ic-card-h2-row .ic-card-h2{margin:0;}
28610 .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;}
28611 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28612 .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;}
28613 .ic-svg-modal-ov.open{display:flex;}
28614 .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);}
28615 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28616 .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);}
28617 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28618 .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;}
28619 .ic-svg-modal-close:hover{background:var(--line);}
28620 .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;}
28621 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28622 .chart-metric-btn:hover:not(.active){background:var(--line);}
28623 .chart-wrap{width:100%;overflow-x:auto;}
28624 #cmp-tl-svg{display:block;width:100%;}
28625 .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);}
28626 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28627 #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;}
28628 </style>
28629</head>
28630<body>
28631 {{ loading_overlay|safe }}
28632 <div class="background-watermarks" aria-hidden="true">
28633 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28634 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28635 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28636 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28637 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28638 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28639 </div>
28640 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28641 <div class="top-nav">
28642 <div class="top-nav-inner">
28643 <a class="brand" href="/">
28644 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28645 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28646 </a>
28647 <div class="nav-right">
28648 <a class="nav-pill" href="/">Home</a>
28649 <div class="nav-dropdown">
28650 <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>
28651 <div class="nav-dropdown-menu">
28652 <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>
28653 </div>
28654 </div>
28655 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28656 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28657 <div class="nav-dropdown">
28658 <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>
28659 <div class="nav-dropdown-menu">
28660 <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>
28661 </div>
28662 </div>
28663 <div class="server-status-wrap" id="server-status-wrap">
28664 <div class="nav-pill server-online-pill" id="server-status-pill">
28665 <span class="status-dot" id="status-dot"></span>
28666 <span id="server-status-label">Server</span>
28667 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28668 </div>
28669 <div class="server-status-tip">
28670 OxideSLOC is running — accessible on your network.
28671 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28672 </div>
28673 </div>
28674 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28675 <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>
28676 </button>
28677 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28678 <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>
28679 <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>
28680 </button>
28681 </div>
28682 </div>
28683 </div>
28684
28685 <div class="page">
28686 <section class="hero">
28687 <div class="hero-header">
28688 <div>
28689 <h1 class="delta-title">Scan Delta</h1>
28690 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28691 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28692 {% if let Some(sub) = active_submodule %}
28693 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28694 {% else if super_scope_active %}
28695 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28696 {% else %}
28697 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28698 {% endif %}
28699 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28700 </div>
28701 </div>
28702 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28703 <a class="btn-back" href="/compare-scans">
28704 <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>
28705 Compare Scans
28706 </a>
28707 <div class="export-group" style="margin-top:12px;">
28708 <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>
28709 <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>
28710 </div>
28711 </div>
28712 </div>
28713 {% if has_any_submodule_data %}
28714 <div class="submod-scope-bar">
28715 <span class="submod-scope-label">
28716 <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>
28717 Scope:
28718 </span>
28719 <div class="submod-scope-divider"></div>
28720 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28721 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
28722 title="All files — super-repo and all submodules combined">Full scan</a>
28723 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28724 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
28725 title="Only files that are not part of any submodule">Super-repo only</a>
28726 {% for sub in submodule_options %}
28727 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28728 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
28729 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28730 {% endfor %}
28731 </div>
28732 {% endif %}
28733 <div class="hero-body">
28734 <div class="meta-strip">
28735 <div class="delta-card delta-card-meta">
28736 <div class="meta-card-header">
28737 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28738 <div class="meta-card-project-col">
28739 <div class="meta-card-project">{{ project_name }}</div>
28740 {% if has_any_submodule_data %}
28741 {% if let Some(sub) = active_submodule %}
28742 <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>
28743 {% else if super_scope_active %}
28744 <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>
28745 {% else %}
28746 <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>
28747 {% endif %}
28748 {% endif %}
28749 </div>
28750 </div>
28751 {% if !baseline_git_commit.is_empty() %}
28752 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28753 {% else %}
28754 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28755 {% endif %}
28756 <div class="meta-card-rows">
28757 <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>
28758 <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>
28759 <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>
28760 <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>
28761 {% if let Some(tags) = baseline_git_tags %}
28762 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28763 {% endif %}
28764 </div>
28765 </div>
28766 <div class="delta-card delta-card-meta">
28767 <div class="meta-card-header">
28768 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28769 <div class="meta-card-project-col">
28770 <div class="meta-card-project">{{ project_name }}</div>
28771 {% if has_any_submodule_data %}
28772 {% if let Some(sub) = active_submodule %}
28773 <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>
28774 {% else if super_scope_active %}
28775 <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>
28776 {% else %}
28777 <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>
28778 {% endif %}
28779 {% endif %}
28780 </div>
28781 </div>
28782 {% if !current_git_commit.is_empty() %}
28783 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28784 {% else %}
28785 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28786 {% endif %}
28787 <div class="meta-card-rows">
28788 <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>
28789 <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>
28790 <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>
28791 <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>
28792 {% if let Some(tags) = current_git_tags %}
28793 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28794 {% endif %}
28795 </div>
28796 </div>
28797 </div>
28798 <div class="delta-strip">
28799 <div class="delta-card">
28800 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28801 <div class="delta-card-label">Code lines</div>
28802 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28803 <div class="delta-card-to">{{ current_code_fmt }}</div>
28804 {% 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>
28805 {% 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>
28806 {% else %}<div class="delta-card-pct zero">±0%</div>
28807 {% endif %}
28808 </div>
28809 <div class="delta-card">
28810 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28811 <div class="delta-card-label">Files analyzed</div>
28812 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28813 <div class="delta-card-to">{{ current_files_fmt }}</div>
28814 {% 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>
28815 {% 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>
28816 {% else %}<div class="delta-card-pct zero">±0%</div>
28817 {% endif %}
28818 </div>
28819 <div class="delta-card">
28820 <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>
28821 <div class="delta-card-label">Comment lines</div>
28822 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28823 <div class="delta-card-to">{{ current_comments_fmt }}</div>
28824 {% 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>
28825 {% 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>
28826 {% else %}<div class="delta-card-pct zero">±0%</div>
28827 {% endif %}
28828 </div>
28829 {{ coverage_delta_card|safe }}
28830 <div class="delta-card delta-card-wide">
28831 <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>
28832 <div class="delta-card-label">File changes</div>
28833 <div class="file-changes-grid">
28834 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28835 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28836 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28837 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28838 </div>
28839 </div>
28840 </div>
28841 <div class="insights-panel">
28842 <div class="insight-card">
28843 <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>
28844 <div class="insight-label">Lines Added</div>
28845 <div class="insight-val pos">+{{ code_lines_added }}</div>
28846 <div class="insight-sub">New or grown source lines</div>
28847 </div>
28848 <div class="insight-card">
28849 <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>
28850 <div class="insight-label">Lines Removed</div>
28851 <div class="insight-val neg">−{{ code_lines_removed }}</div>
28852 <div class="insight-sub">Deleted or shrunk source lines</div>
28853 </div>
28854 <div class="insight-card">
28855 <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>
28856 <div class="insight-label">Churn Rate</div>
28857 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28858 <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>
28859 </div>
28860 {% if scope_flag %}
28861 <div class="insight-card insight-flag">
28862 <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>
28863 <div class="insight-label flag">Scope Signal</div>
28864 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28865 <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>
28866 </div>
28867 {% endif %}
28868 </div>
28869 </div>
28870 </section>
28871
28872 <section class="panel" id="inline-charts-section">
28873 <div class="panel-title">Scan Delta Charts</div>
28874 <div class="ic-grid">
28875 <div class="ic-card" style="grid-column:span 2">
28876 <div class="ic-card-h2-row">
28877 <span class="ic-card-h2">Timeline</span>
28878 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28879 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28880 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28881 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28882 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28883 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28884 </div>
28885 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
28886 </div>
28887 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28888 </div>
28889 <div class="ic-card">
28890 <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>
28891 <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>
28892 <div id="ic-c1"></div>
28893 </div>
28894 <div class="ic-card" id="ic-lang-card">
28895 <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>
28896 <div id="ic-c3"></div>
28897 </div>
28898 <div class="ic-card">
28899 <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>
28900 <div id="ic-c2"></div>
28901 </div>
28902 <div class="ic-card">
28903 <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>
28904 <div id="ic-c4"></div>
28905 </div>
28906 </div>
28907 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28908 <div class="ic-svg-modal">
28909 <div class="ic-svg-modal-hdr">
28910 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28911 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
28912 </div>
28913 <div id="ic-svg-modal-body"></div>
28914 </div>
28915 </div>
28916 </section>
28917
28918 <section class="panel">
28919 <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>
28920 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28921 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28922 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28923 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28924 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28925 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28926 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28927 </div>
28928 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28929 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
28930 <div class="export-group">
28931 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
28932 <button type="button" class="export-btn" id="delta-csv-btn">
28933 <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>
28934 CSV
28935 </button>
28936 <button type="button" class="export-btn" id="delta-xls-btn">
28937 <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>
28938 Excel
28939 </button>
28940 </div>
28941 </div>
28942 </div>
28943
28944 <div class="table-wrap">
28945 <table id="delta-table">
28946 <colgroup>
28947 <col>
28948 <col>
28949 <col>
28950 <col>
28951 <col>
28952 <col>
28953 <col>
28954 </colgroup>
28955 <thead>
28956 <tr id="delta-thead">
28957 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28958 <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>
28959 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28960 <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>
28961 <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>
28962 <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>
28963 <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>
28964 </tr>
28965 </thead>
28966 <tbody id="delta-tbody">
28967 {% for row in file_rows %}
28968 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28969 data-path="{{ row.relative_path }}"
28970 data-language="{{ row.language }}"
28971 data-baseline-code="{{ row.baseline_code }}"
28972 data-current-code="{{ row.current_code }}"
28973 data-code-delta="{{ row.code_delta_str }}"
28974 data-comment-delta="{{ row.comment_delta_str }}"
28975 data-total-delta="{{ row.total_delta_str }}"
28976 data-orig-idx="">
28977 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28978 <td class="hide-sm">{{ row.language }}</td>
28979 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28980 <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>
28981 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28982 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28983 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28984 </tr>
28985 {% endfor %}
28986 </tbody>
28987 </table>
28988 </div>
28989 <div class="pagination">
28990 <span class="pagination-info" id="pg-range-label"></span>
28991 <div class="pagination-btns" id="pg-btns"></div>
28992 <div class="flex-row">
28993 <span class="per-page-label">Show</span>
28994 <select class="per-page" id="per-page-sel">
28995 <option value="10">10 per page</option>
28996 <option value="25" selected>25 per page</option>
28997 <option value="50">50 per page</option>
28998 <option value="100">100 per page</option>
28999 </select>
29000 </div>
29001 </div>
29002 </section>
29003 </div>
29004
29005 <div id="ic-tt"></div>
29006
29007 <footer class="site-footer">
29008 local code analysis - metrics, history and reports
29009 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
29010 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29011 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29012 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29013 · <a href="/api-docs" rel="noopener">REST API</a>
29014 </footer>
29015
29016 <script nonce="{{ csp_nonce }}">
29017 (function () {
29018 var storageKey = 'oxide-sloc-theme';
29019 var body = document.body;
29020 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29021 var toggle = document.getElementById('theme-toggle');
29022 if (toggle) toggle.addEventListener('click', function () {
29023 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29024 body.classList.toggle('dark-theme', next === 'dark');
29025 try { localStorage.setItem(storageKey, next); } catch(e) {}
29026 });
29027
29028 (function randomizeWatermarks() {
29029 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29030 if (!wms.length) return;
29031 var placed = [];
29032 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;}
29033 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];}
29034 var half=Math.floor(wms.length/2);
29035 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;});
29036 })();
29037
29038 (function spawnCodeParticles() {
29039 var container = document.getElementById('code-particles');
29040 if (!container) return;
29041 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'];
29042 for (var i = 0; i < 38; i++) {
29043 (function(idx) {
29044 var el = document.createElement('span');
29045 el.className = 'code-particle';
29046 el.textContent = snippets[idx % snippets.length];
29047 var left = Math.random() * 94 + 2;
29048 var top = Math.random() * 88 + 6;
29049 var dur = (Math.random() * 10 + 9).toFixed(1);
29050 var delay = (Math.random() * 18).toFixed(1);
29051 var rot = (Math.random() * 26 - 13).toFixed(1);
29052 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29053 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';
29054 container.appendChild(el);
29055 })(i);
29056 }
29057 })();
29058 })();
29059
29060 var activeStatusFilter = 'all';
29061 var deltaPerPage = 25, deltaCurrPage = 1;
29062
29063 function openFolder(path) {
29064 fetch('/open-path?path=' + encodeURIComponent(path))
29065 .then(function (r) { return r.json(); })
29066 .then(function (d) {
29067 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29068 })
29069 .catch(function () {});
29070 }
29071
29072 // \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
29073 // The server renders every row once; we lift them into a plain-data array and
29074 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29075 // filtering run on the array (no DOM churn) and each render rebuilds just one
29076 // page (~25 rows). This keeps every interaction O(page) instead of O(all
29077 // files): a 28k-row table previously re-touched every node on each click
29078 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29079 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29080
29081 function parseDeltaNum(str) {
29082 if (!str || str === '\u2014') return 0;
29083 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29084 }
29085
29086 function captureDelta() {
29087 var tbody = document.getElementById('delta-tbody');
29088 if (!tbody) return;
29089 var rows = tbody.querySelectorAll('.delta-row');
29090 for (var i = 0; i < rows.length; i++) {
29091 var r = rows[i];
29092 DELTA.push({
29093 h: r.innerHTML,
29094 cls: r.className,
29095 path: r.getAttribute('data-path') || '',
29096 lang: r.getAttribute('data-language') || '',
29097 status: r.getAttribute('data-status') || '',
29098 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29099 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29100 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29101 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29102 td: parseDeltaNum(r.getAttribute('data-total-delta')),
29103 bcs: r.getAttribute('data-baseline-code') || '',
29104 ccs: r.getAttribute('data-current-code') || '',
29105 cds: r.getAttribute('data-code-delta') || '',
29106 cmds: r.getAttribute('data-comment-delta') || '',
29107 tds: r.getAttribute('data-total-delta') || ''
29108 });
29109 }
29110 tbody.innerHTML = '';
29111 }
29112
29113 function applyDeltaQuery() {
29114 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29115 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29116 if (sortCol) {
29117 var asc = sortOrder === 'asc';
29118 v.sort(function(a, b) {
29119 var va, vb;
29120 if (sortCol === 'path') { va = a.path; vb = b.path; }
29121 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29122 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29123 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29124 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29125 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29126 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29127 else { return 0; }
29128 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29129 return va < vb ? 1 : va > vb ? -1 : 0;
29130 });
29131 }
29132 _deltaView = v;
29133 deltaCurrPage = 1;
29134 renderDeltaPage();
29135 }
29136
29137 function renderDeltaPage() {
29138 var total = _deltaView.length;
29139 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29140 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29141 if (deltaCurrPage < 1) deltaCurrPage = 1;
29142 var start = (deltaCurrPage - 1) * deltaPerPage;
29143 var end = Math.min(start + deltaPerPage, total);
29144 var tbody = document.getElementById('delta-tbody');
29145 if (tbody) {
29146 var html = '';
29147 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29148 tbody.innerHTML = html;
29149 }
29150 var rl = document.getElementById('pg-range-label');
29151 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29152 var btns = document.getElementById('pg-btns');
29153 if (!btns) return;
29154 btns.innerHTML = '';
29155 if (totalPages <= 1) return;
29156 function makeBtn(lbl, pg, active, disabled) {
29157 var b = document.createElement('button');
29158 b.className = 'pg-btn' + (active ? ' active' : '');
29159 b.textContent = lbl; b.disabled = disabled;
29160 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29161 return b;
29162 }
29163 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29164 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29165 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29166 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29167 }
29168
29169 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29170
29171 function filterRows(status, btn) {
29172 activeStatusFilter = status;
29173 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29174 b.classList.remove('active');
29175 });
29176 if (btn) btn.classList.add('active');
29177 applyDeltaQuery();
29178 }
29179
29180 // ── Sorting ──────────────────────────────────────────────────────────────
29181 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29182 sortHeaders.forEach(function(th) {
29183 th.addEventListener('click', function(e) {
29184 if (e.target.classList.contains('col-resize-handle')) return;
29185 var col = th.dataset.sortCol;
29186 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29187 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29188 th.classList.add('sort-' + sortOrder);
29189 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29190 applyDeltaQuery();
29191 });
29192 });
29193
29194 // ── Column resize ─────────────────────────────────────────────────────────
29195 (function() {
29196 var table = document.getElementById('delta-table');
29197 if (!table) return;
29198 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29199 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29200 ths.forEach(function(th, i) {
29201 var handle = th.querySelector('.col-resize-handle');
29202 if (!handle || !cols[i]) return;
29203 handle.addEventListener('mousedown', function(e) {
29204 e.stopPropagation(); e.preventDefault();
29205 // Lock every column to its current rendered px width and size the table
29206 // to the column total. With table-layout:fixed + width:100% the table is
29207 // pinned to the container, so widening one <col> only rebalances the rest
29208 // and the drag looks inert; pinning px widths lets the column actually
29209 // grow while the wrapper (overflow-x:auto) scrolls.
29210 var startTableW = 0;
29211 for (var k = 0; k < ths.length; k++) {
29212 if (!cols[k]) continue;
29213 var w = ths[k].getBoundingClientRect().width;
29214 cols[k].style.width = w + 'px';
29215 startTableW += w;
29216 }
29217 table.style.width = startTableW + 'px';
29218 var startX = e.clientX;
29219 var startW = ths[i].getBoundingClientRect().width;
29220 handle.classList.add('dragging');
29221 function onMove(ev) {
29222 var newW = Math.max(40, startW + ev.clientX - startX);
29223 cols[i].style.width = newW + 'px';
29224 table.style.width = (startTableW + (newW - startW)) + 'px';
29225 }
29226 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29227 document.addEventListener('mousemove', onMove);
29228 document.addEventListener('mouseup', onUp);
29229 });
29230 });
29231 })();
29232
29233 // ── Reset ─────────────────────────────────────────────────────────────────
29234 window.resetDeltaTable = function() {
29235 sortCol = null; sortOrder = 'asc';
29236 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29237 var table = document.getElementById('delta-table');
29238 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29239 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29240 activeStatusFilter = 'all';
29241 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29242 var allBtn = document.querySelector('.tab-btn');
29243 if (allBtn) allBtn.classList.add('active');
29244 applyDeltaQuery();
29245 };
29246
29247 // Compact number formatter (shared by the delta table; charts define their own locally)
29248 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();}
29249 function fmtFull(n){return Number(n).toLocaleString();}
29250
29251 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29252 function fmtFromTo() {
29253 var tbody = document.getElementById('delta-tbody');
29254 if (!tbody) return;
29255 tbody.querySelectorAll('.delta-row').forEach(function(row) {
29256 var status = row.dataset.status || '';
29257 var ft = row.querySelector('.from-to');
29258 if (!ft) return;
29259 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29260 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29261 var strongs = ft.querySelectorAll('strong');
29262 // Apply fmt() to non-absent strong values
29263 strongs.forEach(function(el) {
29264 var n = parseInt(el.textContent, 10);
29265 if (!isNaN(n)) el.textContent = fmtFull(n);
29266 });
29267 // Safety: force dash for genuinely absent sides
29268 if (status === 'added' && bv === 0) {
29269 var bs = ft.querySelector('strong:first-of-type');
29270 if (bs && bs.textContent === '0') {
29271 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29272 }
29273 }
29274 if (status === 'removed' && cv === 0) {
29275 var cs = ft.querySelector('strong:last-of-type');
29276 if (cs && cs.textContent === '0') {
29277 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29278 }
29279 }
29280 });
29281 }
29282 // Initialize: format the server-rendered rows, lift them into the data model
29283 // (which also clears the DOM), then render only the first page.
29284 fmtFromTo();
29285 captureDelta();
29286 applyDeltaQuery();
29287
29288 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29289 (function() {
29290 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29291 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29292 });
29293 var resetBtn = document.getElementById('delta-reset-btn');
29294 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29295 var csvBtn = document.getElementById('delta-csv-btn');
29296 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29297 var xlsBtn = document.getElementById('delta-xls-btn');
29298 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29299 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29300 function sdFetchUri(path) {
29301 return fetch(path).then(function(r){return r.blob();}).then(function(b){
29302 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29303 }).catch(function(){return '';});
29304 }
29305 function sdInlineImgs(html, cb) {
29306 var paths=[], seen={};
29307 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29308 if(!paths.length){cb(html);return;}
29309 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29310 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29311 .catch(function(){cb(html);});
29312 }
29313 function buildFullPageHtml(pdfMode) {
29314 if(pdfMode) document.body.classList.add('pdf-mode');
29315 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29316 renderDeltaPage();
29317 var html = document.documentElement.outerHTML;
29318 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29319 if(pdfMode) document.body.classList.remove('pdf-mode');
29320 return html;
29321 }
29322 var chartsBtn = document.getElementById('delta-charts-btn');
29323 if (chartsBtn) chartsBtn.addEventListener('click', function() {
29324 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29325 sdInlineImgs(buildFullPageHtml(false), function(html) {
29326 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29327 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29328 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29329 btn.disabled=false;btn.innerHTML=orig;
29330 });
29331 });
29332 var pageHtmlBtn = document.getElementById('page-export-html-btn');
29333 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29334 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29335 sdInlineImgs(buildFullPageHtml(false), function(html) {
29336 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29337 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29338 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29339 btn.disabled=false;btn.innerHTML=orig;
29340 });
29341 });
29342 // PDF export — clean document-style report, not a web page screenshot
29343 function buildDeltaPdfHtml() {
29344 var sd=_sd, dr=getDeltaExportRows();
29345 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29346 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+'%';}
29347 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29348 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29349 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29350 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29351 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29352 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29353 function fmtN(n){return Number(n).toLocaleString();}
29354 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29355 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>';}
29356 var lm={};
29357 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;});
29358 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29359 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29360 // The header/footer flow in normal document order (NOT position:fixed).
29361 // A fixed header repeats on every printed page in Chromium and overlaps
29362 // the content beneath it — silently swallowing the first few table rows of
29363 // pages 2+ and clipping the summary cards on page 1. Letting the header
29364 // flow once at the top and relying on the table's <thead> (which Chromium
29365 // repeats per page) keeps every row visible. `.body` keeps a small inset
29366 // so nothing bleeds to the sheet edge.
29367 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29368 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29369 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29370 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29371 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29372 '.ph-brand em{color:#c45c10;font-style:normal;}'+
29373 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29374 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29375 '.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;}'+
29376 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29377 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29378 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29379 '.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;}'+
29380 '.body{padding:12px 18px 0;}'+
29381 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29382 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29383 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29384 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29385 '.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;}'+
29386 '.meta>div{flex:1 1 0;}'+
29387 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29388 '.sec{margin-bottom:10px;}'+
29389 '.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;}'+
29390 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29391 '.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;}'+
29392 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29393 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29394 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29395 '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;}'+
29396 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29397 'tr:nth-child(even) td{background:#faf8f6;}'+
29398 '.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;}'+
29399 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29400 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29401 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29402 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29403 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29404 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29405 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29406 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29407 '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29408 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29409 '.fcc-n{font-size:18px;font-weight:900;}'+
29410 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29411 var fileRows=dchg.map(function(r){
29412 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29413 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29414 '<td style="'+ss+'">'+esc(st)+'</td>'+
29415 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29416 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29417 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29418 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29419 var more='';
29420 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('');
29421 var extraCards='';
29422 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>';}
29423 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>';}
29424 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29425 '<div class="pdf-header">'+
29426 '<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>'+
29427 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29428 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29429 '</div>'+
29430 '<div class="body">'+
29431 '<div class="sec"><p class="sh">Summary Metrics</p>'+
29432 '<div class="msec">'+
29433 '<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>'+
29434 '<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>'+
29435 '<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>'+
29436 '<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>'+
29437 '<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>'+
29438 '<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>'+
29439 extraCards+'</div></div>'+
29440 '<div class="sec"><p class="sh">File Changes</p>'+
29441 '<div class="fcsec">'+
29442 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29443 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29444 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29445 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29446 '</div></div>'+
29447 (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>':'')+
29448 '<div class="sec">'+
29449 '<table><thead>'+
29450 '<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>'+
29451 '<tr><th>File</th><th>Language</th><th>Status</th>'+
29452 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29453 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29454 '</div>'+
29455 '<div class="rfoot">'+
29456 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29457 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29458 '</div>'+
29459 '</body></html>';
29460 }
29461 function doDeltaPdf(btn) {
29462 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29463 }
29464 var pdfBtn = document.getElementById('delta-pdf-btn');
29465 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29466 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29467 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29468 if (location.protocol === 'file:') {
29469 [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'; } });
29470 [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'; } });
29471 }
29472 var ppSel = document.getElementById('per-page-sel');
29473 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29474 var pathLink = document.getElementById('project-path-link');
29475 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29476 })();
29477
29478 // ── Export helpers ────────────────────────────────────────────────────────
29479 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
29480 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29481 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);}
29482 function slocMakeXlsx(fname,sd,dr){
29483 var enc=new TextEncoder();
29484 // CRC-32 table
29485 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;}
29486 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;}
29487 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29488 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29489 // Shared string table
29490 var ss=[],si={};
29491 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29492 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29493 // Worksheet builder — each WS() call gets its own row counter R
29494 function WS(){
29495 var R=0,buf=[];
29496 function cl(c){return String.fromCharCode(65+c);}
29497 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29498 '<v>'+S(v)+'</v></c>';}
29499 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29500 (st?' s="'+st+'"':'')+'>'+
29501 '<v>'+(+v)+'</v></c>';}
29502 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29503 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29504 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29505 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29506 '<sheetFormatPr defaultRowHeight="15"/>'+
29507 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29508 return{sc:sc,nc:nc,row:row,xml:xml};
29509 }
29510 // Language breakdown
29511 var lm={};
29512 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;});
29513 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29514 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29515 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29516 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29517 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29518 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29519 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):'';}
29520 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29521 // Summary sheet
29522 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29523 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29524 r1(s1(0,proj,2));
29525 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29526 r1('');
29527 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29528 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))));
29529 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))));
29530 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))));
29531 r1('');
29532 r1(s1(0,'FILE CHANGES',8));
29533 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29534 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29535 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29536 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29537 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29538 if(langs.length){
29539 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29540 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29541 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)));});
29542 }
29543 r1('');r1(s1(0,'SCAN METADATA',8));
29544 r1(s1(1,_blabel)+s1(2,_clabel));
29545 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29546 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29547 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"/>');
29548 // File Delta sheet
29549 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29550 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));
29551 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)));});
29552 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29553 // Shared strings XML
29554 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29555 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29556 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29557 // XLSX file map
29558 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29559 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>',
29560 '_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>',
29561 '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>',
29562 '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>',
29563 '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>',
29564 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29565 // ZIP packer — STORED (no compression), compatible with all XLSX readers
29566 var zparts=[],zcds=[],zoff=0,znf=0;
29567 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29568 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29569 ].forEach(function(name){
29570 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29571 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]);
29572 var entry=new Uint8Array(lha.length+nb.length+sz);
29573 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29574 zparts.push(entry);
29575 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));
29576 var cde=new Uint8Array(cda.length+nb.length);
29577 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29578 zcds.push(cde);zoff+=entry.length;znf++;
29579 });
29580 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29581 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]);
29582 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29583 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29584 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29585 zout.set(new Uint8Array(ea),zpos);
29586 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29587 var xurl=URL.createObjectURL(xblob);
29588 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29589 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29590 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29591 }
29592 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;');}
29593 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29594 function getExportFilename(ext){return _exportBase+'.'+ext;}
29595
29596 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 }}'};
29597 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;}
29598 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29599 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29600 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29601 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29602 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):'';}
29603 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29604 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)]];}
29605 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29606 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)];});}
29607 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29608 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29609
29610 // ── Chart HTML report ─────────────────────────────────────────────────────
29611 function slocChartReport(fname, sd, dr) {
29612 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29613 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29614 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29615 function fmt(n){return Number(n).toLocaleString();}
29616 function px(n){return Math.round(n);}
29617 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29618 // Language map
29619 var lm={};
29620 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;});
29621 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29622
29623 // Builds onmouse* attrs for interactive tooltip on each SVG element
29624 function barTT(label,val){
29625 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29626 }
29627
29628 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29629 // match the Language Code Delta column height) ────────────
29630 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'}];
29631 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29632 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29633 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29634 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29635 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"/>';}
29636 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29637 c1mets.forEach(function(m,i){
29638 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29639 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29640 var gMax=Math.max(m.b,m.c)*1.15||1;
29641 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29642 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>';
29643 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))+'/>';
29644 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>';
29645 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))+'/>';
29646 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>';
29647 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>';
29648 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>';
29649 });
29650 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>';
29651 c1+='</svg>';
29652
29653 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29654 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'}];
29655 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29656 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29657 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29658 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29659 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29660 mets.forEach(function(m,i){
29661 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29662 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29663 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29664 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>';
29665 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29666 if(bw>=52){
29667 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>';
29668 }else{
29669 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29670 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>';
29671 }
29672 });
29673 c2+='</svg>';
29674
29675 // ── Chart 3: Language Code Delta ─────────────────────────────────────
29676 var c3='';
29677 if(langs.length){
29678 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29679 var C3W=550,c3LW=124,c3FW=52;
29680 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29681 var L3rH=30,C3H=langs.length*L3rH+20;
29682 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29683 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29684 langs.forEach(function(l,i){
29685 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29686 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29687 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29688 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>';
29689 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':''))+'/>';
29690 if(bw>=48){
29691 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>';
29692 }else{
29693 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29694 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>';
29695 }
29696 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>';
29697 });
29698 c3+='</svg>';
29699 }
29700
29701 // ── Chart 4: File Change Donut — centered pie with legend below
29702 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;});
29703 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29704 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29705 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">';
29706 var ang=-Math.PI/2;
29707 segs.forEach(function(s){
29708 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29709 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29710 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29711 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29712 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29713 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)+'%')+'/>';
29714 ang+=sw;
29715 });
29716 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>';
29717 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>';
29718 segs.forEach(function(s,i){
29719 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29720 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29721 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>';
29722 });
29723 c4+='</svg>';
29724
29725 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29726 var ttJs='var tt=document.getElementById("ox-tt");'+
29727 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29728 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29729 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29730 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29731 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29732 'function oxHT(){tt.style.display="none";}';
29733
29734 // body max-width keeps charts from inflating beyond design dimensions on
29735 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29736 // each chart's height blows up proportionally, breaking the one-page layout.
29737 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;}'+
29738 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29739 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29740 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29741 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29742 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29743 'svg{display:block;}'+
29744 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29745 '#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;}'+
29746 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29747 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29748 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29749 '<div id="ox-tt"><\/div>'+
29750 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
29751 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
29752 '<div class="two-col">'+
29753 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
29754 '<div class="leg">'+
29755 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29756 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29757 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29758 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
29759 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29760 '<\/div>'+
29761 '<div class="two-col">'+
29762 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29763 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29764 '<\/div>'+
29765 '<script>'+ttJs+'<\/script>'+
29766 '<\/body><\/html>';
29767 slocDownload(html, fname, 'text/html;charset=utf-8;');
29768 }
29769 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29770 window.buildDeltaChartsHtml = function() {
29771 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29772 var sd=_sd;
29773 var projEl=document.querySelector('[data-folder]');
29774 var proj=projEl?projEl.getAttribute('data-folder'):'';
29775 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29776 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29777 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29778 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29779 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";}';
29780 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);}';
29781 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29782 '<div id="ox-tt"><\/div>'+
29783 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29784 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29785 '<div class="two-col">'+
29786 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29787 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29788 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29789 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29790 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29791 '<\/div>'+
29792 '<div class="two-col">'+
29793 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29794 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29795 '<\/div>'+
29796 '<script>'+ttJs+'<\/script>'+
29797 '<\/body><\/html>';
29798 };
29799 // ── Inline delta charts ────────────────────────────────────────────────────
29800 var _icTT=document.getElementById('ic-tt');
29801 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29802 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';};
29803 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29804 window.addEventListener('blur',function(){window.icHT();});
29805 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29806 (function(){
29807 // Theme-aware palette — matches the canonical scheme used by /test-metrics
29808 // charts so every page renders bars/text/grid with the same colours and
29809 // adapts to dark mode (see Design section in CLAUDE.md).
29810 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29811 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29812 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29813 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29814 // washed) so the chart reads with the same weight as /test-metrics.
29815 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29816 var FADE=dark?'#524238':'#e6d0bf';
29817 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29818 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29819 function fmt(n){return Number(n).toLocaleString();}
29820 function px(n){return Math.round(n);}
29821 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29822 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29823 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);});}
29824 var dr=getDeltaExportRows(),sd=_sd,lm={};
29825 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;});
29826 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29827 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29828 // the bars are as tall as the (usually taller) Language Code Delta sibling that
29829 // shares the same grid row, instead of sitting short at the top.
29830 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}];
29831 function drawC1(){
29832 var C1W=600,C1H=188;
29833 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29834 if(host&&card&&host.clientWidth>0){
29835 var avW=host.clientWidth;
29836 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29837 var wantH=availPx*C1W/avW;
29838 if(wantH>C1H)C1H=wantH;
29839 }
29840 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29841 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29842 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"/>';}
29843 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29844 c1mets.forEach(function(m,i){
29845 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29846 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29847 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29848 var gMax=Math.max(m.b,m.c)*1.15||1;
29849 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29850 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>';
29851 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"/>';
29852 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>';
29853 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"/>';
29854 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>';
29855 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>';
29856 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>';
29857 });
29858 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>';
29859 c1+='</svg>';
29860 return c1;
29861 }
29862 var c1=drawC1();
29863 // Chart 2: Delta by Metric
29864 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}];
29865 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29866 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;
29867 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29868 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29869 mets.forEach(function(m,i){
29870 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);
29871 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>';
29872 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29873 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>';}
29874 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>';}
29875 });
29876 c2+='</svg>';
29877 // Chart 3: Language Code Delta
29878 var c3='';
29879 if(langs.length){
29880 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29881 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;
29882 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29883 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29884 langs.forEach(function(l,i){
29885 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);
29886 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>';
29887 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"/>';
29888 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>';}
29889 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>';}
29890 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>';
29891 });
29892 c3+='</svg>';
29893 }
29894 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29895 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29896 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;});
29897 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29898 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29899 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);
29900 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;
29901 if(segs.length===1){
29902 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29903 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+'"/>';
29904 } else {
29905 // Give every visible slice a small minimum sweep, taken from the largest
29906 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29907 // arc whose start and end points coincide, so SVG renders nothing (blank).
29908 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29909 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29910 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29911 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29912 segs.forEach(function(s,si){
29913 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29914 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);
29915 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);
29916 var pct=Math.round(s.v/tot*100);
29917 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"/>';
29918 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>';}
29919 ang+=sw;
29920 });
29921 }
29922 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29923 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29924 segs.forEach(function(s,i){
29925 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29926 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29927 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29928 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29929 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29930 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>';
29931 c4+='</g>';
29932 });
29933 c4+='</svg>';
29934 // Inject the fixed-height siblings first so the grid row settles to the (taller)
29935 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29936 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29937 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);}
29938 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29939 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29940 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29941
29942 // Compare Timeline chart (Baseline vs Current, 2 points)
29943 (function() {
29944 var activeCmpMetric='code';
29945 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29946 function renderCmpTL(metric, targetSvg, targetH) {
29947 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29948 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29949 svg.setAttribute('height',H);
29950 var pad={l:62,r:20,t:32,b:72};
29951 var dark=document.body.classList.contains('dark-theme');
29952 var cmpPts=[
29953 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29954 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29955 ];
29956 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29957 var valid=pts.filter(function(v){return v!=null;});
29958 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;}
29959 var minV=0,maxV=Math.max.apply(null,valid);
29960 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29961 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29962 var cx0=pad.l,cx1=pad.l+plotW;
29963 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29964 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29965 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29966 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29967 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29968 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();}
29969 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29970 var parts=[];
29971 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29972 for(var gi=0;gi<5;gi++){
29973 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29974 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29975 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29976 }
29977 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+'"/>');
29978 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"/>');
29979 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29980 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29981 dotPts.forEach(function(pt){
29982 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>');
29983 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"/>');
29984 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>');
29985 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>');
29986 });
29987 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29988 svg.setAttribute('viewBox','0 0 '+W+' '+H);
29989 svg.innerHTML=parts.join('');
29990 // Hover: crosshair + tooltip (matches multi-scan timeline)
29991 var cmpTT=document.getElementById('ic-tt');
29992 svg.onmousemove=function(e){
29993 var rect=svg.getBoundingClientRect();
29994 var scaleX=W/rect.width;
29995 var mouseX=(e.clientX-rect.left)*scaleX;
29996 var nearest=-1,minDist=Infinity;
29997 var cxArr=[cx0,cx1];
29998 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;}}
29999 if(nearest<0)return;
30000 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30001 var xhair=svg.querySelector('.cmp-xhair');
30002 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30003 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"/>';
30004 if(!cmpTT)return;
30005 var clbl=cmpPts[nearest].label;
30006 var scanLbl=nearest===0?'Baseline':'Current';
30007 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>';
30008 var bx=rect.left+(nc/W*rect.width)+18;
30009 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30010 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30011 };
30012 svg.onmouseleave=function(){
30013 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30014 if(cmpTT)cmpTT.style.display='none';
30015 };
30016 }
30017 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30018 btn.addEventListener('click',function(){
30019 activeCmpMetric=this.dataset.cmpMetric;
30020 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30021 this.classList.add('active');
30022 renderCmpTL(activeCmpMetric);
30023 });
30024 });
30025 var ttgl=document.getElementById('theme-toggle');
30026 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30027 if(typeof ResizeObserver!=='undefined'){
30028 var cmpSvg=document.getElementById('cmp-tl-svg');
30029 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30030 }
30031 // Expose the timeline renderer + current metric so the Full View modal can
30032 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30033 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30034 window.__sdGetMetric=function(){return activeCmpMetric;};
30035 renderCmpTL(activeCmpMetric);
30036 })();
30037
30038 // HTML legend hover -> highlight matching SVG bars within the SAME card only
30039 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30040 var metric=leg.getAttribute('data-highlight');
30041 var parentCard=leg.closest('.ic-card');
30042 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30043 if(!chartEl)return;
30044 leg.addEventListener('mouseenter',function(){
30045 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30046 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';}
30047 else{x.style.opacity='0.28';}
30048 });
30049 });
30050 leg.addEventListener('mouseleave',function(){
30051 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30052 });
30053 });
30054
30055 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30056 (function(){
30057 var ov=document.getElementById('ic-svg-modal-ov');
30058 var body=document.getElementById('ic-svg-modal-body');
30059 var ttl=document.getElementById('ic-svg-modal-title');
30060 var closeBtn=document.getElementById('ic-svg-modal-close');
30061 if(!ov||!body)return;
30062 function close(){
30063 ov.classList.remove('open');body.innerHTML='';
30064 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30065 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30066 }
30067 function open(srcId,title){
30068 var src=document.getElementById(srcId);if(!src)return;
30069 if(ttl)ttl.textContent=title||'';
30070 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30071 // snapshot stretches and loses interactivity. Re-render it live into the modal at
30072 // full size instead — keeps proportions, animation, crosshair, tooltip and the
30073 // metric tabs working exactly like the inline chart.
30074 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30075 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30076 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30077 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('');
30078 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>';
30079 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30080 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30081 ov.classList.add('open');
30082 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30083 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;}
30084 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30085 b.addEventListener('click',function(){
30086 if(!window.__sdFvTL)return;
30087 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30088 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30089 this.classList.add('active');
30090 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30091 });
30092 });
30093 return;
30094 }
30095 var card=src.closest('.ic-card');
30096 var legHtml='';
30097 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30098 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30099 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;}
30100 body.innerHTML=legHtml+inner;
30101 var svg=body.querySelector('svg');
30102 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30103 addTT(body);
30104 ov.classList.add('open');
30105 }
30106 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30107 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30108 });
30109 if(closeBtn)closeBtn.addEventListener('click',close);
30110 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30111 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30112 })();
30113
30114 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30115 })();
30116 </script>
30117 {{ toast_assets|safe }}
30118 <script nonce="{{ csp_nonce }}">
30119 (function(){
30120 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'}];
30121 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);});}
30122 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30123 function init(){
30124 var btn=document.getElementById('settings-btn');if(!btn)return;
30125 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30126 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>';
30127 document.body.appendChild(m);
30128 var g=document.getElementById('scheme-grid');
30129 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);});
30130 var cl=document.getElementById('settings-close');
30131 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
30132 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');});
30133 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30134 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30135 }
30136 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30137 }());
30138 </script>
30139 <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]';
30140 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;}
30141 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>
30142</body>
30143</html>
30144"##,
30145 ext = "html"
30146)]
30147#[allow(clippy::struct_excessive_bools)]
30149struct CompareTemplate {
30150 loading_overlay: String,
30152 version: &'static str,
30153 project_label: String,
30154 baseline_git_commit: String,
30155 current_git_commit: String,
30156 baseline_run_id: String,
30157 current_run_id: String,
30158 baseline_run_id_short: String,
30159 current_run_id_short: String,
30160 baseline_timestamp: String,
30161 baseline_timestamp_utc_ms: i64,
30162 current_timestamp: String,
30163 current_timestamp_utc_ms: i64,
30164 project_path: String,
30165 baseline_code: u64,
30166 current_code: u64,
30167 code_lines_delta_str: String,
30168 code_lines_delta_class: String,
30169 baseline_files: u64,
30170 current_files: u64,
30171 files_analyzed_delta_str: String,
30172 files_analyzed_delta_class: String,
30173 baseline_comments: u64,
30174 current_comments: u64,
30175 comment_lines_delta_str: String,
30176 comment_lines_delta_class: String,
30177 baseline_code_fmt: String,
30178 current_code_fmt: String,
30179 baseline_files_fmt: String,
30180 current_files_fmt: String,
30181 baseline_comments_fmt: String,
30182 current_comments_fmt: String,
30183 code_lines_pct_str: String,
30184 files_analyzed_pct_str: String,
30185 comment_lines_pct_str: String,
30186 code_lines_added: i64,
30187 code_lines_removed: i64,
30188 new_scope: bool,
30190 churn_rate_str: String,
30191 churn_rate_class: String,
30192 scope_flag: bool,
30193 files_added: usize,
30194 files_removed: usize,
30195 files_modified: usize,
30196 files_unchanged: usize,
30197 file_rows: Vec<CompareFileDeltaRow>,
30198 baseline_git_author: Option<String>,
30199 current_git_author: Option<String>,
30200 baseline_git_branch: String,
30201 current_git_branch: String,
30202 baseline_git_tags: Option<String>,
30203 current_git_tags: Option<String>,
30204 baseline_git_commit_date: Option<String>,
30205 current_git_commit_date: Option<String>,
30206 project_name: String,
30207 submodule_options: Vec<String>,
30209 has_any_submodule_data: bool,
30211 active_submodule: Option<String>,
30213 super_scope_active: bool,
30215 csp_nonce: String,
30216 toast_assets: String,
30218 coverage_delta_card: String,
30220 baseline_test_count: u64,
30221 current_test_count: u64,
30222 baseline_coverage_pct: Option<f64>,
30223 current_coverage_pct: Option<f64>,
30224}
30225
30226#[derive(Template)]
30229#[template(
30230 source = r##"
30231<!doctype html>
30232<html lang="en">
30233<head>
30234 <meta charset="utf-8">
30235 <meta name="viewport" content="width=device-width, initial-scale=1">
30236 <title>OxideSLOC | Sign In</title>
30237 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30238 <style nonce="{{ csp_nonce }}">
30239 :root {
30240 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30241 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30242 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30243 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30244 }
30245 *{box-sizing:border-box;}
30246 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);}
30247 .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);}
30248 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30249 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30250 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30251 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30252 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30253 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30254 .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;}
30255 @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));}}
30256 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30257 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30258 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30259 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30260 .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;}
30261 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30262 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;}
30263 input[type=password]:focus{border-color:var(--oxide);}
30264 .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;}
30265 .btn:hover{opacity:.88;}
30266 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30267 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30268 </style>
30269</head>
30270<body>
30271 <div class="background-watermarks" aria-hidden="true">
30272 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30273 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30274 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30275 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30276 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30277 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30278 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30279 </div>
30280 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30281<nav class="top-nav">
30282 <a class="brand" href="/">
30283 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30284 <span class="brand-title">OxideSLOC</span>
30285 </a>
30286</nav>
30287<main class="page">
30288 <div class="card">
30289 <h1>Sign In</h1>
30290 <p class="subtitle">Enter the API key printed when the server started.</p>
30291 {% if has_error %}
30292 <div class="error">Incorrect API key — please try again.</div>
30293 {% endif %}
30294 <form method="POST" action="/auth/login">
30295 <input type="hidden" name="next" value="{{ next_url|e }}">
30296 <label for="key">API Key</label>
30297 <input id="key" type="password" name="key" autocomplete="current-password"
30298 placeholder="Paste your API key here" autofocus>
30299 <button type="submit" class="btn">Sign In</button>
30300 </form>
30301 <p class="hint">
30302 The API key was printed in the terminal when the server started.<br>
30303 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30304 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30305 </p>
30306 </div>
30307</main>
30308<script nonce="{{ csp_nonce }}">
30309(function() {
30310 (function randomizeWatermarks() {
30311 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30312 if (!wms.length) return;
30313 var placed = [];
30314 function tooClose(top, left) {
30315 for (var i = 0; i < placed.length; i++) {
30316 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30317 if (dt < 16 && dl < 12) return true;
30318 }
30319 return false;
30320 }
30321 function pick(leftBand) {
30322 for (var attempt = 0; attempt < 50; attempt++) {
30323 var top = Math.random() * 88 + 2;
30324 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30325 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30326 }
30327 var top = Math.random() * 88 + 2;
30328 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30329 placed.push([top, left]); return [top, left];
30330 }
30331 var half = Math.floor(wms.length / 2);
30332 wms.forEach(function (img, i) {
30333 var pos = pick(i < half);
30334 var size = Math.floor(Math.random() * 100 + 120);
30335 var rot = (Math.random() * 360).toFixed(1);
30336 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30337 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;
30338 });
30339 })();
30340 (function spawnCodeParticles() {
30341 var container = document.getElementById('code-particles');
30342 if (!container) return;
30343 var snippets = [
30344 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30345 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30346 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30347 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30348 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30349 ];
30350 var count = 38;
30351 for (var i = 0; i < count; i++) {
30352 (function(idx) {
30353 var el = document.createElement('span');
30354 el.className = 'code-particle';
30355 el.textContent = snippets[idx % snippets.length];
30356 var left = Math.random() * 94 + 2;
30357 var top = Math.random() * 88 + 6;
30358 var dur = (Math.random() * 10 + 9).toFixed(1);
30359 var delay = (Math.random() * 18).toFixed(1);
30360 var rot = (Math.random() * 26 - 13).toFixed(1);
30361 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30362 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30363 container.appendChild(el);
30364 })(i);
30365 }
30366 })();
30367})();
30368</script>
30369</body>
30370</html>
30371"##,
30372 ext = "html"
30373)]
30374pub(crate) struct LoginTemplate {
30375 pub(crate) csp_nonce: String,
30376 pub(crate) has_error: bool,
30377 pub(crate) next_url: String,
30378 pub(crate) lockout_threshold: u32,
30379}
30380
30381#[derive(Template)]
30384#[template(
30385 source = r##"
30386<!doctype html>
30387<html lang="en">
30388<head>
30389 <meta charset="utf-8">
30390 <meta name="viewport" content="width=device-width, initial-scale=1">
30391 <title>OxideSLOC — REST API Reference</title>
30392 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30393 <style nonce="{{ csp_nonce }}">
30394 :root {
30395 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30396 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30397 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30398 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30399 --success:#16a34a;
30400 }
30401 body.dark-theme {
30402 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30403 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30404 }
30405 *{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;}
30406 .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);}
30407 .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;}
30408 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30409 .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));}
30410 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30411 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30412 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30413 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30414 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30415 @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; } }
30416 .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;}
30417 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30418 .nav-pill.active{background:rgba(255,255,255,0.22);}
30419 .nav-dropdown{position:relative;display:inline-flex;}
30420 .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;}
30421 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30422 .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;}
30423 .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;}
30424 .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);}
30425 .nav-dropdown-menu a:last-child{border-bottom:none;}
30426 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30427 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30428 .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;}
30429 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30430 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30431 .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
30432 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30433 .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);}
30434 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30435 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30436 .settings-modal-body{padding:14px 16px 16px;}
30437 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30438 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30439 .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;}
30440 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30441 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30442 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30443 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30444 .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;}
30445 .tz-select:focus{border-color:var(--oxide);}
30446 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30447 .page-header{margin-bottom:28px;}
30448 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30449 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30450 .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;}
30451 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30452 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30453 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30454 .callout strong{font-weight:800;}
30455 .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;}
30456 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30457 .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;}
30458 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30459 .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;}
30460 body.dark-theme .base-url-value{color:var(--accent);}
30461 .section{margin-bottom:36px;}
30462 .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);}
30463 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30464 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30465 .ep-header:hover{background:var(--surface-2);}
30466 .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;}
30467 .method.get{background:#dcfce7;color:#166534;}
30468 .method.post{background:#dbeafe;color:#1e40af;}
30469 .method.delete{background:#fee2e2;color:#991b1b;}
30470 body.dark-theme .method.get{background:#14532d;color:#86efac;}
30471 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30472 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30473 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30474 .ep-path .param{color:var(--oxide-2);}
30475 body.dark-theme .ep-path .param{color:var(--oxide);}
30476 .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;}
30477 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30478 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30479 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30480 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30481 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30482 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30483 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30484 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30485 .ep-card.open .chevron{transform:rotate(180deg);}
30486 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30487 .ep-card.open .ep-body{display:block;}
30488 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30489 .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;}
30490 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30491 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30492 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30493 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30494 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);}
30495 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30496 table.params tr:last-child td{border-bottom:none;}
30497 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30498 .pt-type{color:var(--muted-2);font-size:12px;}
30499 .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;}
30500 .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;}
30501 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30502 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30503 details.schema{margin-bottom:14px;}
30504 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;}
30505 details.schema summary:hover{color:var(--text);}
30506 .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;}
30507 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30508 .curl-wrap{position:relative;}
30509 .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;}
30510 .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;}
30511 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30512 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30513 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30514 .webhook-note a{color:var(--accent-2);text-decoration:none;}
30515 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30516 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30517 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30518 .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;}
30519 @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));}}
30520 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30521 .site-footer a{color:var(--muted);}
30522 </style>
30523</head>
30524<body>
30525 <div class="background-watermarks" aria-hidden="true">
30526 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30527 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30528 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30529 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30530 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30531 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30532 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30533 </div>
30534 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30535 <div class="top-nav">
30536 <div class="top-nav-inner">
30537 <a class="brand" href="/">
30538 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30539 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30540 </a>
30541 <div class="nav-right">
30542 <a class="nav-pill" href="/">Home</a>
30543 <div class="nav-dropdown">
30544 <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>
30545 <div class="nav-dropdown-menu">
30546 <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>
30547 </div>
30548 </div>
30549 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30550 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30551 <div class="nav-dropdown">
30552 <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>
30553 <div class="nav-dropdown-menu">
30554 <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>
30555 </div>
30556 </div>
30557 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30558 <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>
30559 </button>
30560 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30561 <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>
30562 <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>
30563 </button>
30564 </div>
30565 </div>
30566 </div>
30567
30568 <div class="page">
30569 <div class="page-header">
30570 <h1 class="page-title">REST API Reference</h1>
30571 <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>
30572 </div>
30573
30574 {% if has_api_key %}
30575 <div class="callout key-set">
30576 <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>
30577 <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>
30578 </div>
30579 {% else %}
30580 <div class="callout no-key">
30581 <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>
30582 <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>
30583 </div>
30584 {% endif %}
30585
30586 <div class="base-url-bar">
30587 <span class="base-url-label">Base URL</span>
30588 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30589 </div>
30590
30591 <!-- Health -->
30592 <div class="section">
30593 <h2 class="section-title">Health & Status</h2>
30594 <div class="ep-card">
30595 <div class="ep-header">
30596 <span class="method get">GET</span>
30597 <span class="ep-path">/healthz</span>
30598 <span class="auth-badge public">Public</span>
30599 <span class="ep-desc">Server liveness check</span>
30600 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30601 </div>
30602 <div class="ep-body">
30603 <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>
30604 <p class="params-heading">Response</p>
30605 <div class="schema-block">200 OK
30606Content-Type: text/plain
30607
30608ok</div>
30609 <p class="curl-heading">Example</p>
30610 <div class="curl-wrap">
30611 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30612 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30613 </div>
30614 </div>
30615 </div>
30616 </div>
30617
30618 <!-- Badges -->
30619 <div class="section">
30620 <h2 class="section-title">Badges</h2>
30621 <div class="ep-card">
30622 <div class="ep-header">
30623 <span class="method get">GET</span>
30624 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30625 <span class="auth-badge public">Public</span>
30626 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30627 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30628 </div>
30629 <div class="ep-body">
30630 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30631 <p class="params-heading">Path Parameters</p>
30632 <table class="params">
30633 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30634 <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>
30635 </table>
30636 <p class="curl-heading">Example</p>
30637 <div class="curl-wrap">
30638 <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>
30639 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30640 </div>
30641 </div>
30642 </div>
30643 </div>
30644
30645 <!-- Metrics -->
30646 <div class="section">
30647 <h2 class="section-title">Metrics</h2>
30648
30649 <div class="ep-card">
30650 <div class="ep-header">
30651 <span class="method get">GET</span>
30652 <span class="ep-path">/api/metrics/latest</span>
30653 <span class="auth-badge protected">Protected</span>
30654 <span class="ep-desc">Latest scan metrics (JSON)</span>
30655 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30656 </div>
30657 <div class="ep-body">
30658 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30659 <details class="schema"><summary>Response schema</summary>
30660<div class="schema-block">{
30661 "run_id": string, // UUID
30662 "timestamp": string, // ISO-8601 UTC
30663 "project": string, // scanned root path
30664 "summary": {
30665 "files_analyzed": number,
30666 "files_skipped": number,
30667 "code_lines": number,
30668 "comment_lines": number,
30669 "blank_lines": number,
30670 "total_physical_lines": number,
30671 "functions": number,
30672 "classes": number,
30673 "variables": number,
30674 "imports": number
30675 },
30676 "languages": [
30677 { "name": string, "files": number, "code_lines": number,
30678 "comment_lines": number, "blank_lines": number,
30679 "functions": number, "classes": number,
30680 "variables": number, "imports": number }
30681 ]
30682}</div></details>
30683 <p class="curl-heading">Example</p>
30684 <div class="curl-wrap">
30685 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30686 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30687 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30688 </div>
30689 </div>
30690 </div>
30691
30692 <div class="ep-card">
30693 <div class="ep-header">
30694 <span class="method get">GET</span>
30695 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30696 <span class="auth-badge protected">Protected</span>
30697 <span class="ep-desc">Metrics for a specific run</span>
30698 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30699 </div>
30700 <div class="ep-body">
30701 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30702 <p class="params-heading">Path Parameters</p>
30703 <table class="params">
30704 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30705 <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>
30706 </table>
30707 <p class="curl-heading">Example</p>
30708 <div class="curl-wrap">
30709 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30710 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
30711 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30712 </div>
30713 </div>
30714 </div>
30715
30716 <div class="ep-card">
30717 <div class="ep-header">
30718 <span class="method get">GET</span>
30719 <span class="ep-path">/api/metrics/history</span>
30720 <span class="auth-badge protected">Protected</span>
30721 <span class="ep-desc">Paginated scan history</span>
30722 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30723 </div>
30724 <div class="ep-body">
30725 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30726 <p class="params-heading">Query Parameters</p>
30727 <table class="params">
30728 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30729 <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>
30730 <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>
30731 </table>
30732 <details class="schema"><summary>Response schema</summary>
30733<div class="schema-block">[{
30734 "run_id": string,
30735 "timestamp": string, // ISO-8601 UTC
30736 "commit": string | null,
30737 "branch": string | null,
30738 "tags": string[],
30739 "code_lines": number,
30740 "comment_lines": number,
30741 "blank_lines": number,
30742 "physical_lines": number,
30743 "files_analyzed": number,
30744 "project_label": string,
30745 "html_url": string | null
30746}]</div></details>
30747 <p class="curl-heading">Example</p>
30748 <div class="curl-wrap">
30749 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30750 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30751 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30752 </div>
30753 </div>
30754 </div>
30755
30756 <div class="ep-card">
30757 <div class="ep-header">
30758 <span class="method get">GET</span>
30759 <span class="ep-path">/api/project-history</span>
30760 <span class="auth-badge protected">Protected</span>
30761 <span class="ep-desc">Project-level scan summary</span>
30762 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30763 </div>
30764 <div class="ep-body">
30765 <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>
30766 <p class="params-heading">Query Parameters</p>
30767 <table class="params">
30768 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30769 <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>
30770 </table>
30771 <details class="schema"><summary>Response schema</summary>
30772<div class="schema-block">{
30773 "scan_count": number,
30774 "last_scan_id": string | null,
30775 "last_scan_timestamp": string | null, // ISO-8601
30776 "last_scan_code_lines": number | null,
30777 "last_git_branch": string | null,
30778 "last_git_commit": string | null
30779}</div></details>
30780 <p class="curl-heading">Example</p>
30781 <div class="curl-wrap">
30782 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30783 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30784 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30785 </div>
30786 </div>
30787 </div>
30788
30789 <div class="ep-card">
30790 <div class="ep-header">
30791 <span class="method get">GET</span>
30792 <span class="ep-path">/api/metrics/submodules</span>
30793 <span class="auth-badge protected">Protected</span>
30794 <span class="ep-desc">List known git submodules across scans</span>
30795 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30796 </div>
30797 <div class="ep-body">
30798 <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>
30799 <p class="params-heading">Query Parameters</p>
30800 <table class="params">
30801 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30802 <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>
30803 </table>
30804 <details class="schema"><summary>Response schema</summary>
30805<div class="schema-block">[{
30806 "name": string, // submodule name
30807 "relative_path": string // path relative to the project root
30808}]</div></details>
30809 <p class="curl-heading">Example</p>
30810 <div class="curl-wrap">
30811 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30812 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30813 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30814 </div>
30815 </div>
30816 </div>
30817 </div>
30818
30819 <!-- Async Run Status -->
30820 <div class="section">
30821 <h2 class="section-title">Async Run Status</h2>
30822
30823 <div class="ep-card">
30824 <div class="ep-header">
30825 <span class="method get">GET</span>
30826 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30827 <span class="auth-badge protected">Protected</span>
30828 <span class="ep-desc">Poll scan completion</span>
30829 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30830 </div>
30831 <div class="ep-body">
30832 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30833 <details class="schema"><summary>Response schema</summary>
30834<div class="schema-block">// Running
30835{ "state": "running", "elapsed_secs": number }
30836
30837// Complete
30838{ "state": "complete", "run_id": string }
30839
30840// Failed
30841{ "state": "failed", "message": string }</div></details>
30842 <p class="curl-heading">Example</p>
30843 <div class="curl-wrap">
30844 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30845 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
30846 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30847 </div>
30848 </div>
30849 </div>
30850
30851 <div class="ep-card">
30852 <div class="ep-header">
30853 <span class="method get">GET</span>
30854 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30855 <span class="auth-badge protected">Protected</span>
30856 <span class="ep-desc">Poll PDF generation readiness</span>
30857 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30858 </div>
30859 <div class="ep-body">
30860 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30861 <details class="schema"><summary>Response schema</summary>
30862<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30863 <p class="curl-heading">Example</p>
30864 <div class="curl-wrap">
30865 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30866 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
30867 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30868 </div>
30869 </div>
30870 </div>
30871
30872 <div class="ep-card">
30873 <div class="ep-header">
30874 <span class="method post">POST</span>
30875 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30876 <span class="auth-badge protected">Protected</span>
30877 <span class="ep-desc">Cancel a running scan</span>
30878 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30879 </div>
30880 <div class="ep-body">
30881 <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>
30882 <p class="curl-heading">Example</p>
30883 <div class="curl-wrap">
30884 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30885 -H "Authorization: Bearer $SLOC_API_KEY" \
30886 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
30887 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30888 </div>
30889 </div>
30890 </div>
30891 </div>
30892
30893 <!-- Run Management -->
30894 <div class="section">
30895 <h2 class="section-title">Run Management</h2>
30896
30897 <div class="ep-card">
30898 <div class="ep-header">
30899 <span class="method get">GET</span>
30900 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30901 <span class="auth-badge protected">Protected</span>
30902 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30903 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30904 </div>
30905 <div class="ep-body">
30906 <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>
30907 <p class="params-heading">Path Parameters</p>
30908 <table class="params">
30909 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30910 <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>
30911 </table>
30912 <details class="schema"><summary>Response</summary>
30913<div class="schema-block">200 OK — Content-Type: application/zip
30914Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
30915
30916404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
30917 <p class="curl-heading">Example</p>
30918 <div class="curl-wrap">
30919 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30920 -o run.zip \
30921 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
30922 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30923 </div>
30924 </div>
30925 </div>
30926
30927 <div class="ep-card">
30928 <div class="ep-header">
30929 <span class="method delete">DELETE</span>
30930 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30931 <span class="auth-badge protected">Protected</span>
30932 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30933 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30934 </div>
30935 <div class="ep-body">
30936 <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>
30937 <p class="params-heading">Path Parameters</p>
30938 <table class="params">
30939 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30940 <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>
30941 </table>
30942 <details class="schema"><summary>Response</summary>
30943<div class="schema-block">204 No Content — run successfully deleted
30944
30945500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
30946 <p class="curl-heading">Example</p>
30947 <div class="curl-wrap">
30948 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30949 -H "Authorization: Bearer $SLOC_API_KEY" \
30950 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
30951 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30952 </div>
30953 </div>
30954 </div>
30955
30956 <div class="ep-card">
30957 <div class="ep-header">
30958 <span class="method post">POST</span>
30959 <span class="ep-path">/api/runs/cleanup</span>
30960 <span class="auth-badge protected">Protected</span>
30961 <span class="ep-desc">Bulk delete runs older than N days</span>
30962 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30963 </div>
30964 <div class="ep-body">
30965 <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>
30966 <p class="params-heading">Request Body (application/json)</p>
30967 <table class="params">
30968 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30969 <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>
30970 </table>
30971 <details class="schema"><summary>Response schema</summary>
30972<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
30973 <p class="curl-heading">Example — delete runs older than 60 days</p>
30974 <div class="curl-wrap">
30975 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30976 -H "Authorization: Bearer $SLOC_API_KEY" \
30977 -H "Content-Type: application/json" \
30978 -d '{"older_than_days":60}' \
30979 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30980 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30981 </div>
30982 </div>
30983 </div>
30984 </div>
30985
30986 <!-- Retention Policy -->
30987 <div class="section">
30988 <h2 class="section-title">Retention Policy</h2>
30989
30990 <div class="ep-card">
30991 <div class="ep-header">
30992 <span class="method get">GET</span>
30993 <span class="ep-path">/api/cleanup-policy</span>
30994 <span class="auth-badge protected">Protected</span>
30995 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30996 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30997 </div>
30998 <div class="ep-body">
30999 <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>
31000 <details class="schema"><summary>Response schema</summary>
31001<div class="schema-block">{
31002 "policy": {
31003 "enabled": boolean,
31004 "max_age_days": number | null, // delete runs older than N days
31005 "max_run_count": number | null, // keep only the N most recent runs
31006 "interval_hours": number // hours between background passes
31007 } | null,
31008 "last_run_at": string | null, // ISO-8601 UTC timestamp
31009 "last_run_deleted": number | null // runs deleted in last pass
31010}</div></details>
31011 <p class="curl-heading">Example</p>
31012 <div class="curl-wrap">
31013 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31014 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31015 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31016 </div>
31017 </div>
31018 </div>
31019
31020 <div class="ep-card">
31021 <div class="ep-header">
31022 <span class="method post">POST</span>
31023 <span class="ep-path">/api/cleanup-policy</span>
31024 <span class="auth-badge protected">Protected</span>
31025 <span class="ep-desc">Save or update the retention policy</span>
31026 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31027 </div>
31028 <div class="ep-body">
31029 <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>
31030 <p class="params-heading">Request Body (application/json)</p>
31031 <table class="params">
31032 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31033 <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>
31034 <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>
31035 <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>
31036 <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>
31037 </table>
31038 <details class="schema"><summary>Response</summary>
31039<div class="schema-block">204 No Content — policy saved and task (re)started
31040
31041500 Internal Server Error — { "error": string }</div></details>
31042 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31043 <div class="curl-wrap">
31044 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31045 -H "Authorization: Bearer $SLOC_API_KEY" \
31046 -H "Content-Type: application/json" \
31047 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31048 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31049 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31050 </div>
31051 </div>
31052 </div>
31053
31054 <div class="ep-card">
31055 <div class="ep-header">
31056 <span class="method post">POST</span>
31057 <span class="ep-path">/api/cleanup-policy/run-now</span>
31058 <span class="auth-badge protected">Protected</span>
31059 <span class="ep-desc">Trigger an immediate cleanup pass</span>
31060 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31061 </div>
31062 <div class="ep-body">
31063 <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>
31064 <details class="schema"><summary>Response schema</summary>
31065<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
31066 <p class="curl-heading">Example</p>
31067 <div class="curl-wrap">
31068 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31069 -H "Authorization: Bearer $SLOC_API_KEY" \
31070 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31071 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31072 </div>
31073 </div>
31074 </div>
31075
31076 <div class="ep-card">
31077 <div class="ep-header">
31078 <span class="method delete">DELETE</span>
31079 <span class="ep-path">/api/cleanup-policy</span>
31080 <span class="auth-badge protected">Protected</span>
31081 <span class="ep-desc">Remove the retention policy and stop the background task</span>
31082 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31083 </div>
31084 <div class="ep-body">
31085 <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>
31086 <details class="schema"><summary>Response</summary>
31087<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31088 <p class="curl-heading">Example</p>
31089 <div class="curl-wrap">
31090 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31091 -H "Authorization: Bearer $SLOC_API_KEY" \
31092 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31093 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31094 </div>
31095 </div>
31096 </div>
31097 </div>
31098
31099 <!-- Scan Profiles -->
31100 <div class="section">
31101 <h2 class="section-title">Scan Profiles</h2>
31102
31103 <div class="ep-card">
31104 <div class="ep-header">
31105 <span class="method get">GET</span>
31106 <span class="ep-path">/api/scan-profiles</span>
31107 <span class="auth-badge protected">Protected</span>
31108 <span class="ep-desc">List saved scan profiles</span>
31109 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31110 </div>
31111 <div class="ep-body">
31112 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31113 <details class="schema"><summary>Response schema</summary>
31114<div class="schema-block">{
31115 "profiles": [{
31116 "id": string, // UUID
31117 "name": string,
31118 "created_at": string, // ISO-8601
31119 "params": object
31120 }]
31121}</div></details>
31122 <p class="curl-heading">Example</p>
31123 <div class="curl-wrap">
31124 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31125 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31126 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31127 </div>
31128 </div>
31129 </div>
31130
31131 <div class="ep-card">
31132 <div class="ep-header">
31133 <span class="method post">POST</span>
31134 <span class="ep-path">/api/scan-profiles</span>
31135 <span class="auth-badge protected">Protected</span>
31136 <span class="ep-desc">Save a scan profile</span>
31137 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31138 </div>
31139 <div class="ep-body">
31140 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31141 <p class="params-heading">Request Body (application/json)</p>
31142 <table class="params">
31143 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31144 <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>
31145 <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>
31146 </table>
31147 <details class="schema"><summary>Response schema</summary>
31148<div class="schema-block">{ "ok": true }</div></details>
31149 <p class="curl-heading">Example</p>
31150 <div class="curl-wrap">
31151 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31152 -H "Authorization: Bearer $SLOC_API_KEY" \
31153 -H "Content-Type: application/json" \
31154 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31155 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31156 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31157 </div>
31158 </div>
31159 </div>
31160
31161 <div class="ep-card">
31162 <div class="ep-header">
31163 <span class="method delete">DELETE</span>
31164 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31165 <span class="auth-badge protected">Protected</span>
31166 <span class="ep-desc">Delete a scan profile</span>
31167 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31168 </div>
31169 <div class="ep-body">
31170 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31171 <p class="params-heading">Path Parameters</p>
31172 <table class="params">
31173 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31174 <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>
31175 </table>
31176 <details class="schema"><summary>Response schema</summary>
31177<div class="schema-block">{ "ok": true }</div></details>
31178 <p class="curl-heading">Example</p>
31179 <div class="curl-wrap">
31180 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31181 -H "Authorization: Bearer $SLOC_API_KEY" \
31182 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31183 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31184 </div>
31185 </div>
31186 </div>
31187 </div>
31188
31189 <!-- Scheduled Scans -->
31190 <div class="section">
31191 <h2 class="section-title">Scheduled Scans</h2>
31192
31193 <div class="ep-card">
31194 <div class="ep-header">
31195 <span class="method get">GET</span>
31196 <span class="ep-path">/api/schedules</span>
31197 <span class="auth-badge protected">Protected</span>
31198 <span class="ep-desc">List configured schedules</span>
31199 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31200 </div>
31201 <div class="ep-body">
31202 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31203 <p class="curl-heading">Example</p>
31204 <div class="curl-wrap">
31205 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31206 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31207 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31208 </div>
31209 </div>
31210 </div>
31211
31212 <div class="ep-card">
31213 <div class="ep-header">
31214 <span class="method post">POST</span>
31215 <span class="ep-path">/api/schedules</span>
31216 <span class="auth-badge protected">Protected</span>
31217 <span class="ep-desc">Create a schedule</span>
31218 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31219 </div>
31220 <div class="ep-body">
31221 <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>
31222 <p class="curl-heading">Example</p>
31223 <div class="curl-wrap">
31224 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31225 -H "Authorization: Bearer $SLOC_API_KEY" \
31226 -H "Content-Type: application/json" \
31227 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31228 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31229 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31230 </div>
31231 </div>
31232 </div>
31233
31234 <div class="ep-card">
31235 <div class="ep-header">
31236 <span class="method delete">DELETE</span>
31237 <span class="ep-path">/api/schedules</span>
31238 <span class="auth-badge protected">Protected</span>
31239 <span class="ep-desc">Delete a schedule</span>
31240 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31241 </div>
31242 <div class="ep-body">
31243 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31244 <p class="curl-heading">Example</p>
31245 <div class="curl-wrap">
31246 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31247 -H "Authorization: Bearer $SLOC_API_KEY" \
31248 -H "Content-Type: application/json" \
31249 -d '{"id":"<schedule_id>"}' \
31250 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31251 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31252 </div>
31253 </div>
31254 </div>
31255 </div>
31256
31257 <!-- Git Browser -->
31258 <div class="section">
31259 <h2 class="section-title">Git Browser</h2>
31260
31261 <div class="ep-card">
31262 <div class="ep-header">
31263 <span class="method get">GET</span>
31264 <span class="ep-path">/api/git/refs</span>
31265 <span class="auth-badge protected">Protected</span>
31266 <span class="ep-desc">List git refs for a repository</span>
31267 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31268 </div>
31269 <div class="ep-body">
31270 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31271 <p class="params-heading">Query Parameters</p>
31272 <table class="params">
31273 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31274 <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>
31275 </table>
31276 <p class="curl-heading">Example</p>
31277 <div class="curl-wrap">
31278 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31279 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31280 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31281 </div>
31282 </div>
31283 </div>
31284
31285 <div class="ep-card">
31286 <div class="ep-header">
31287 <span class="method get">GET</span>
31288 <span class="ep-path">/api/git/scan-ref</span>
31289 <span class="auth-badge protected">Protected</span>
31290 <span class="ep-desc">SLOC-scan a specific git ref</span>
31291 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31292 </div>
31293 <div class="ep-body">
31294 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31295 <p class="params-heading">Query Parameters</p>
31296 <table class="params">
31297 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31298 <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>
31299 <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>
31300 </table>
31301 <p class="curl-heading">Example</p>
31302 <div class="curl-wrap">
31303 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31304 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
31305 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31306 </div>
31307 </div>
31308 </div>
31309
31310 <div class="ep-card">
31311 <div class="ep-header">
31312 <span class="method get">GET</span>
31313 <span class="ep-path">/api/git/compare-refs</span>
31314 <span class="auth-badge protected">Protected</span>
31315 <span class="ep-desc">Compare SLOC across two git refs</span>
31316 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31317 </div>
31318 <div class="ep-body">
31319 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31320 <p class="params-heading">Query Parameters</p>
31321 <table class="params">
31322 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31323 <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>
31324 <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>
31325 <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>
31326 </table>
31327 <p class="curl-heading">Example</p>
31328 <div class="curl-wrap">
31329 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31330 "<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>
31331 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31332 </div>
31333 </div>
31334 </div>
31335 </div>
31336
31337 <!-- Webhooks -->
31338 <div class="section">
31339 <h2 class="section-title">Webhooks</h2>
31340 <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>
31341
31342 <div class="ep-card">
31343 <div class="ep-header">
31344 <span class="method post">POST</span>
31345 <span class="ep-path">/webhooks/github</span>
31346 <span class="auth-badge hmac">HMAC</span>
31347 <span class="ep-desc">GitHub push event receiver</span>
31348 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31349 </div>
31350 <div class="ep-body">
31351 <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>
31352 <p class="params-heading">Required Headers</p>
31353 <table class="params">
31354 <tr><th>Header</th><th>Value</th></tr>
31355 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31356 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31357 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31358 </table>
31359 </div>
31360 </div>
31361
31362 <div class="ep-card">
31363 <div class="ep-header">
31364 <span class="method post">POST</span>
31365 <span class="ep-path">/webhooks/gitlab</span>
31366 <span class="auth-badge hmac">HMAC</span>
31367 <span class="ep-desc">GitLab push event receiver</span>
31368 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31369 </div>
31370 <div class="ep-body">
31371 <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>
31372 <p class="params-heading">Required Headers</p>
31373 <table class="params">
31374 <tr><th>Header</th><th>Value</th></tr>
31375 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31376 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31377 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31378 </table>
31379 </div>
31380 </div>
31381
31382 <div class="ep-card">
31383 <div class="ep-header">
31384 <span class="method post">POST</span>
31385 <span class="ep-path">/webhooks/bitbucket</span>
31386 <span class="auth-badge hmac">HMAC</span>
31387 <span class="ep-desc">Bitbucket push event receiver</span>
31388 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31389 </div>
31390 <div class="ep-body">
31391 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31392 <p class="params-heading">Required Headers</p>
31393 <table class="params">
31394 <tr><th>Header</th><th>Value</th></tr>
31395 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31396 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31397 </table>
31398 </div>
31399 </div>
31400 </div>
31401
31402 <!-- Config -->
31403 <div class="section">
31404 <h2 class="section-title">Config Import / Export</h2>
31405
31406 <div class="ep-card">
31407 <div class="ep-header">
31408 <span class="method get">GET</span>
31409 <span class="ep-path">/export-config</span>
31410 <span class="auth-badge protected">Protected</span>
31411 <span class="ep-desc">Export server configuration as JSON</span>
31412 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31413 </div>
31414 <div class="ep-body">
31415 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31416 <p class="curl-heading">Example</p>
31417 <div class="curl-wrap">
31418 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31419 -o config.json \
31420 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31421 <button class="curl-copy-btn" data-target="c-export">Copy</button>
31422 </div>
31423 </div>
31424 </div>
31425
31426 <div class="ep-card">
31427 <div class="ep-header">
31428 <span class="method post">POST</span>
31429 <span class="ep-path">/import-config</span>
31430 <span class="auth-badge protected">Protected</span>
31431 <span class="ep-desc">Import server configuration</span>
31432 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31433 </div>
31434 <div class="ep-body">
31435 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31436 <p class="curl-heading">Example</p>
31437 <div class="curl-wrap">
31438 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31439 -H "Authorization: Bearer $SLOC_API_KEY" \
31440 -H "Content-Type: application/json" \
31441 -d @config.json \
31442 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31443 <button class="curl-copy-btn" data-target="c-import">Copy</button>
31444 </div>
31445 </div>
31446 </div>
31447 </div>
31448
31449 <!-- CI Ingest -->
31450 <div class="section">
31451 <h2 class="section-title">CI Ingest</h2>
31452
31453 <div class="ep-card">
31454 <div class="ep-header">
31455 <span class="method post">POST</span>
31456 <span class="ep-path">/api/ingest</span>
31457 <span class="auth-badge protected">Protected</span>
31458 <span class="ep-desc">Push a pre-computed scan result from CI</span>
31459 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31460 </div>
31461 <div class="ep-body">
31462 <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>
31463 <p class="params-heading">Query Parameters</p>
31464 <table class="params">
31465 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31466 <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>
31467 </table>
31468 <p class="params-heading">Request Body (application/json)</p>
31469 <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>
31470 <details class="schema"><summary>Response schema</summary>
31471<div class="schema-block">// 201 Created
31472{
31473 "run_id": string, // UUID of the ingested run
31474 "view_url": string // relative URL to the report page
31475}</div></details>
31476 <p class="curl-heading">Example</p>
31477 <div class="curl-wrap">
31478 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31479 -H "Authorization: Bearer $SLOC_API_KEY" \
31480 -H "Content-Type: application/json" \
31481 -d @result.json \
31482 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31483 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31484 </div>
31485 </div>
31486 </div>
31487 </div>
31488
31489 <!-- Artifact Download -->
31490 <div class="section">
31491 <h2 class="section-title">Artifact Download</h2>
31492
31493 <div class="ep-card">
31494 <div class="ep-header">
31495 <span class="method get">GET</span>
31496 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31497 <span class="auth-badge protected">Protected</span>
31498 <span class="ep-desc">Download or view a scan artifact</span>
31499 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31500 </div>
31501 <div class="ep-body">
31502 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31503 <p class="params-heading">Path Parameters</p>
31504 <table class="params">
31505 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31506 <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>
31507 <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>
31508 </table>
31509 <p class="params-heading">Query Parameters</p>
31510 <table class="params">
31511 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31512 <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>
31513 </table>
31514 <p class="curl-heading">Example — download JSON result</p>
31515 <div class="curl-wrap">
31516 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31517 -o result.json \
31518 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
31519 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31520 </div>
31521 </div>
31522 </div>
31523 </div>
31524
31525 <!-- Embed Widget -->
31526 <div class="section">
31527 <h2 class="section-title">Embed Widget</h2>
31528
31529 <div class="ep-card">
31530 <div class="ep-header">
31531 <span class="method get">GET</span>
31532 <span class="ep-path">/embed/summary</span>
31533 <span class="auth-badge protected">Protected</span>
31534 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31535 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31536 </div>
31537 <div class="ep-body">
31538 <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>
31539 <p class="params-heading">Query Parameters</p>
31540 <table class="params">
31541 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31542 <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>
31543 <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>
31544 </table>
31545 <p class="curl-heading">Example</p>
31546 <div class="curl-wrap">
31547 <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"
31548 width="460" height="260" style="border:none"></iframe></pre>
31549 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31550 </div>
31551 </div>
31552 </div>
31553 </div>
31554
31555 <!-- Confluence Integration -->
31556 <div class="section">
31557 <h2 class="section-title">Confluence Integration</h2>
31558
31559 <div class="ep-card">
31560 <div class="ep-header">
31561 <span class="method get">GET</span>
31562 <span class="ep-path">/api/confluence/config</span>
31563 <span class="auth-badge protected">Protected</span>
31564 <span class="ep-desc">Get current Confluence configuration</span>
31565 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31566 </div>
31567 <div class="ep-body">
31568 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31569 <details class="schema"><summary>Response schema</summary>
31570<div class="schema-block">{
31571 "configured": boolean,
31572 "tier": "cloud" | "server",
31573 "base_url": string,
31574 "username": string,
31575 "api_token_set": boolean,
31576 "space_key": string,
31577 "parent_page_id": string | null,
31578 "schedule_auto_post": { "<schedule_id>": boolean }
31579}</div></details>
31580 <p class="curl-heading">Example</p>
31581 <div class="curl-wrap">
31582 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31583 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31584 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31585 </div>
31586 </div>
31587 </div>
31588
31589 <div class="ep-card">
31590 <div class="ep-header">
31591 <span class="method post">POST</span>
31592 <span class="ep-path">/api/confluence/config</span>
31593 <span class="auth-badge protected">Protected</span>
31594 <span class="ep-desc">Save Confluence configuration</span>
31595 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31596 </div>
31597 <div class="ep-body">
31598 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31599 <p class="params-heading">Request Body (application/json)</p>
31600 <table class="params">
31601 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31602 <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>
31603 <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>
31604 <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>
31605 <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>
31606 <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>
31607 <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>
31608 <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>
31609 </table>
31610 <details class="schema"><summary>Response schema</summary>
31611<div class="schema-block">{ "ok": true }</div></details>
31612 <p class="curl-heading">Example</p>
31613 <div class="curl-wrap">
31614 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31615 -H "Authorization: Bearer $SLOC_API_KEY" \
31616 -H "Content-Type: application/json" \
31617 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31618 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31619 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31620 </div>
31621 </div>
31622 </div>
31623
31624 <div class="ep-card">
31625 <div class="ep-header">
31626 <span class="method post">POST</span>
31627 <span class="ep-path">/api/confluence/test</span>
31628 <span class="auth-badge protected">Protected</span>
31629 <span class="ep-desc">Test Confluence connection</span>
31630 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31631 </div>
31632 <div class="ep-body">
31633 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31634 <details class="schema"><summary>Response schema</summary>
31635<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31636 <p class="curl-heading">Example</p>
31637 <div class="curl-wrap">
31638 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31639 -H "Authorization: Bearer $SLOC_API_KEY" \
31640 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31641 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31642 </div>
31643 </div>
31644 </div>
31645
31646 <div class="ep-card">
31647 <div class="ep-header">
31648 <span class="method post">POST</span>
31649 <span class="ep-path">/api/confluence/post</span>
31650 <span class="auth-badge protected">Protected</span>
31651 <span class="ep-desc">Publish a scan report to Confluence</span>
31652 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31653 </div>
31654 <div class="ep-body">
31655 <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>
31656 <p class="params-heading">Request Body (application/json)</p>
31657 <table class="params">
31658 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31659 <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>
31660 <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>
31661 <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>
31662 </table>
31663 <details class="schema"><summary>Response schema</summary>
31664<div class="schema-block">// 200 OK
31665{ "ok": true, "page_id": string }
31666
31667// 400 / 502 on error
31668{ "ok": false, "error": string }</div></details>
31669 <p class="curl-heading">Example</p>
31670 <div class="curl-wrap">
31671 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31672 -H "Authorization: Bearer $SLOC_API_KEY" \
31673 -H "Content-Type: application/json" \
31674 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
31675 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31676 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31677 </div>
31678 </div>
31679 </div>
31680
31681 <div class="ep-card">
31682 <div class="ep-header">
31683 <span class="method get">GET</span>
31684 <span class="ep-path">/api/confluence/wiki-markup</span>
31685 <span class="auth-badge protected">Protected</span>
31686 <span class="ep-desc">Get Confluence wiki markup for a run</span>
31687 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31688 </div>
31689 <div class="ep-body">
31690 <p class="ep-desc-full">Returns the Confluence Storage Format (XHTML) markup that would be posted for the given run, so you can preview or extend it before publishing.</p>
31691 <p class="params-heading">Query Parameters</p>
31692 <table class="params">
31693 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31694 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run to generate markup for</td></tr>
31695 </table>
31696 <p class="curl-heading">Example</p>
31697 <div class="curl-wrap">
31698 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31699 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
31700 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31701 </div>
31702 </div>
31703 </div>
31704 </div>
31705
31706 <!-- Authentication -->
31707 <div class="section">
31708 <h2 class="section-title">Authentication</h2>
31709 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31710
31711 <div class="ep-card">
31712 <div class="ep-header">
31713 <span class="method get">GET</span>
31714 <span class="ep-path">/auth/login</span>
31715 <span class="auth-badge public">Public</span>
31716 <span class="ep-desc">Login page</span>
31717 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31718 </div>
31719 <div class="ep-body">
31720 <p class="ep-desc-full">Returns the HTML login form. Redirects to <code>/</code> immediately when no API key is configured on the server.</p>
31721 <p class="params-heading">Query Parameters</p>
31722 <table class="params">
31723 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31724 <tr><td class="pt-name">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>
31725 <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>
31726 </table>
31727 </div>
31728 </div>
31729
31730 <div class="ep-card">
31731 <div class="ep-header">
31732 <span class="method post">POST</span>
31733 <span class="ep-path">/auth/login</span>
31734 <span class="auth-badge public">Public</span>
31735 <span class="ep-desc">Submit credentials and get a session cookie</span>
31736 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31737 </div>
31738 <div class="ep-body">
31739 <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>
31740 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31741 <table class="params">
31742 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31743 <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>
31744 <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>
31745 </table>
31746 <p class="curl-heading">Example</p>
31747 <div class="curl-wrap">
31748 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31749 -d "key=$SLOC_API_KEY&next=/" \
31750 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31751 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31752 </div>
31753 </div>
31754 </div>
31755 </div>
31756
31757 <!-- Coverage Suggestion -->
31758 <div class="section">
31759 <h2 class="section-title">Coverage Suggestion</h2>
31760
31761 <div class="ep-card">
31762 <div class="ep-header">
31763 <span class="method get">GET</span>
31764 <span class="ep-path">/api/suggest-coverage</span>
31765 <span class="auth-badge protected">Protected</span>
31766 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31767 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31768 </div>
31769 <div class="ep-body">
31770 <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>
31771 <p class="params-heading">Query Parameters</p>
31772 <table class="params">
31773 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31774 <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>
31775 </table>
31776 <details class="schema"><summary>Response schema</summary>
31777<div class="schema-block">{
31778 "found": string | null, // absolute path to the coverage file, if detected
31779 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31780 "hint": string | null // shell command to generate coverage if not found
31781}</div></details>
31782 <p class="curl-heading">Example</p>
31783 <div class="curl-wrap">
31784 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31785 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31786 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31787 </div>
31788 </div>
31789 </div>
31790 </div>
31791
31792 </div>
31793
31794 <footer class="site-footer">
31795 local code analysis - metrics, history and reports
31796 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
31797 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31798 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31799 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31800 · <a href="/api-docs" rel="noopener">REST API</a>
31801 </footer>
31802
31803 <script nonce="{{ csp_nonce }}">
31804 (function () {
31805 var base = window.location.origin;
31806 document.getElementById('base-url').textContent = base;
31807 document.querySelectorAll('.base-url-slot').forEach(function (el) {
31808 el.textContent = base;
31809 });
31810
31811 document.querySelectorAll('.ep-header').forEach(function (hdr) {
31812 hdr.addEventListener('click', function () {
31813 hdr.closest('.ep-card').classList.toggle('open');
31814 });
31815 });
31816
31817 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31818 btn.addEventListener('click', function () {
31819 var targetId = btn.dataset.target;
31820 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31821 if (!pre) return;
31822 navigator.clipboard.writeText(pre.textContent).then(function () {
31823 btn.textContent = 'Copied!';
31824 btn.classList.add('copied');
31825 setTimeout(function () {
31826 btn.textContent = 'Copy';
31827 btn.classList.remove('copied');
31828 }, 2000);
31829 });
31830 });
31831 });
31832
31833 var storageKey = 'oxide-sloc-theme';
31834 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31835 var themeBtn = document.getElementById('theme-toggle');
31836 if (themeBtn) {
31837 themeBtn.addEventListener('click', function () {
31838 var dark = document.body.classList.toggle('dark-theme');
31839 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31840 });
31841 }
31842 (function() {
31843 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'}];
31844 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);});}
31845 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31846 var btn=document.getElementById('settings-btn');if(!btn)return;
31847 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31848 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>';
31849 document.body.appendChild(m);
31850 var g=document.getElementById('scheme-grid');
31851 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);});
31852 var cl=document.getElementById('settings-close');
31853 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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
31854 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');});
31855 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31856 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31857 })();
31858 (function randomizeWatermarks() {
31859 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31860 if (!wms.length) return;
31861 var placed = [];
31862 function tooClose(top, left) {
31863 for (var i = 0; i < placed.length; i++) {
31864 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31865 if (dt < 16 && dl < 12) return true;
31866 }
31867 return false;
31868 }
31869 function pick(leftBand) {
31870 for (var attempt = 0; attempt < 50; attempt++) {
31871 var top = Math.random() * 88 + 2;
31872 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31873 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31874 }
31875 var top = Math.random() * 88 + 2;
31876 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31877 placed.push([top, left]); return [top, left];
31878 }
31879 var half = Math.floor(wms.length / 2);
31880 wms.forEach(function (img, i) {
31881 var pos = pick(i < half);
31882 var size = Math.floor(Math.random() * 100 + 120);
31883 var rot = (Math.random() * 360).toFixed(1);
31884 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31885 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;
31886 });
31887 })();
31888 (function spawnCodeParticles() {
31889 var container = document.getElementById('code-particles');
31890 if (!container) return;
31891 var snippets = [
31892 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31893 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31894 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31895 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31896 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31897 ];
31898 var count = 38;
31899 for (var i = 0; i < count; i++) {
31900 (function(idx) {
31901 var el = document.createElement('span');
31902 el.className = 'code-particle';
31903 el.textContent = snippets[idx % snippets.length];
31904 var left = Math.random() * 94 + 2;
31905 var top = Math.random() * 88 + 6;
31906 var dur = (Math.random() * 10 + 9).toFixed(1);
31907 var delay = (Math.random() * 18).toFixed(1);
31908 var rot = (Math.random() * 26 - 13).toFixed(1);
31909 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31910 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31911 container.appendChild(el);
31912 })(i);
31913 }
31914 })();
31915 }());
31916 </script>
31917</body>
31918</html>
31919"##,
31920 ext = "html"
31921)]
31922struct ApiDocsTemplate {
31923 has_api_key: bool,
31924 csp_nonce: String,
31925 version: &'static str,
31926}
31927
31928#[cfg(test)]
31929mod form_config_tests {
31930 use super::*;
31931 use sloc_config::{
31932 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31933 };
31934
31935 fn blank_form() -> AnalyzeForm {
31936 AnalyzeForm {
31937 path: ".".to_string(),
31938 git_repo: None,
31939 git_ref: None,
31940 mixed_line_policy: None,
31941 python_docstrings_as_comments: None,
31942 generated_file_detection: None,
31943 minified_file_detection: None,
31944 vendor_directory_detection: None,
31945 include_lockfiles: None,
31946 binary_file_behavior: None,
31947 output_dir: None,
31948 report_title: None,
31949 report_header_footer: None,
31950 include_globs: None,
31951 exclude_globs: None,
31952 submodule_breakdown: None,
31953 coverage_file: None,
31954 continuation_line_policy: None,
31955 blank_in_block_comment_policy: None,
31956 count_compiler_directives: None,
31957 style_col_threshold: None,
31958 style_analysis_enabled: None,
31959 style_score_threshold: None,
31960 style_lang_scope: None,
31961 cocomo_mode: None,
31962 complexity_alert: None,
31963 exclude_duplicates: None,
31964 activity_window: None,
31965 }
31966 }
31967
31968 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31969 let mut cfg = sloc_config::AppConfig::default();
31970 apply_form_to_config(&mut cfg, form);
31971 cfg
31972 }
31973
31974 #[test]
31977 fn extract_long_commit_picks_super_repo_by_short_prefix() {
31978 let dir = tempfile::tempdir().unwrap();
31982 let path = dir.path().join("result.json");
31983 let body = r#"{
31984 "submodules": [
31985 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31986 { "git_commit_long": null }
31987 ],
31988 "git_commit_short": "4c2cd9b",
31989 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31990}"#;
31991 std::fs::write(&path, body).unwrap();
31992 assert_eq!(
31993 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31994 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31995 );
31996 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31998 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31999 }
32000
32001 #[test]
32002 fn activity_window_defaults_on_when_field_blank() {
32003 let cfg = apply(&blank_form());
32005 assert_eq!(cfg.analysis.activity_window_days, Some(90));
32006 }
32007
32008 #[test]
32009 fn activity_window_override_sets_days() {
32010 let mut form = blank_form();
32011 form.activity_window = Some("30".to_string());
32012 let cfg = apply(&form);
32013 assert_eq!(cfg.analysis.activity_window_days, Some(30));
32014 }
32015
32016 #[test]
32017 fn activity_window_zero_disables() {
32018 let mut form = blank_form();
32020 form.activity_window = Some("0".to_string());
32021 let cfg = apply(&form);
32022 assert_eq!(cfg.analysis.activity_window_days, Some(0));
32023 }
32024
32025 #[test]
32028 fn python_docstrings_false_when_unchecked() {
32029 let cfg = apply(&blank_form());
32031 assert!(
32032 !cfg.analysis.python_docstrings_as_comments,
32033 "absent python_docstrings_as_comments must map to false"
32034 );
32035 }
32036
32037 #[test]
32038 fn python_docstrings_true_when_checked() {
32039 let mut form = blank_form();
32041 form.python_docstrings_as_comments = Some("on".to_string());
32042 let cfg = apply(&form);
32043 assert!(cfg.analysis.python_docstrings_as_comments);
32044 }
32045
32046 #[test]
32047 fn python_docstrings_true_for_any_non_none_value() {
32048 let mut form = blank_form();
32050 form.python_docstrings_as_comments = Some("true".to_string());
32051 assert!(apply(&form).analysis.python_docstrings_as_comments);
32052 }
32053
32054 #[test]
32057 fn submodule_breakdown_false_when_unchecked() {
32058 let cfg = apply(&blank_form());
32059 assert!(
32060 !cfg.discovery.submodule_breakdown,
32061 "absent submodule_breakdown must map to false"
32062 );
32063 }
32064
32065 #[test]
32066 fn submodule_breakdown_true_when_value_enabled() {
32067 let mut form = blank_form();
32068 form.submodule_breakdown = Some("enabled".to_string());
32069 assert!(apply(&form).discovery.submodule_breakdown);
32070 }
32071
32072 #[test]
32073 fn submodule_breakdown_false_for_wrong_value() {
32074 let mut form = blank_form();
32076 form.submodule_breakdown = Some("on".to_string());
32077 assert!(
32078 !apply(&form).discovery.submodule_breakdown,
32079 "submodule_breakdown only becomes true for the exact value 'enabled'"
32080 );
32081 }
32082
32083 #[test]
32086 fn generated_detection_true_when_enabled() {
32087 let mut form = blank_form();
32088 form.generated_file_detection = Some("enabled".to_string());
32089 assert!(apply(&form).analysis.generated_file_detection);
32090 }
32091
32092 #[test]
32093 fn generated_detection_false_when_disabled() {
32094 let mut form = blank_form();
32095 form.generated_file_detection = Some("disabled".to_string());
32096 assert!(!apply(&form).analysis.generated_file_detection);
32097 }
32098
32099 #[test]
32100 fn generated_detection_true_when_absent() {
32101 assert!(
32103 apply(&blank_form()).analysis.generated_file_detection,
32104 "absent field must default to true (detection on)"
32105 );
32106 }
32107
32108 #[test]
32111 fn minified_detection_false_when_disabled() {
32112 let mut form = blank_form();
32113 form.minified_file_detection = Some("disabled".to_string());
32114 assert!(!apply(&form).analysis.minified_file_detection);
32115 }
32116
32117 #[test]
32118 fn minified_detection_true_when_enabled() {
32119 let mut form = blank_form();
32120 form.minified_file_detection = Some("enabled".to_string());
32121 assert!(apply(&form).analysis.minified_file_detection);
32122 }
32123
32124 #[test]
32125 fn minified_detection_true_when_absent() {
32126 assert!(apply(&blank_form()).analysis.minified_file_detection);
32127 }
32128
32129 #[test]
32132 fn vendor_detection_false_when_disabled() {
32133 let mut form = blank_form();
32134 form.vendor_directory_detection = Some("disabled".to_string());
32135 assert!(!apply(&form).analysis.vendor_directory_detection);
32136 }
32137
32138 #[test]
32139 fn vendor_detection_true_when_enabled() {
32140 let mut form = blank_form();
32141 form.vendor_directory_detection = Some("enabled".to_string());
32142 assert!(apply(&form).analysis.vendor_directory_detection);
32143 }
32144
32145 #[test]
32146 fn vendor_detection_true_when_absent() {
32147 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32148 }
32149
32150 #[test]
32153 fn lockfiles_false_when_absent() {
32154 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32156 }
32157
32158 #[test]
32159 fn lockfiles_false_when_disabled() {
32160 let mut form = blank_form();
32161 form.include_lockfiles = Some("disabled".to_string());
32162 assert!(!apply(&form).analysis.include_lockfiles);
32163 }
32164
32165 #[test]
32166 fn lockfiles_true_when_enabled() {
32167 let mut form = blank_form();
32168 form.include_lockfiles = Some("enabled".to_string());
32169 assert!(apply(&form).analysis.include_lockfiles);
32170 }
32171
32172 #[test]
32175 fn compiler_directives_true_when_absent() {
32176 assert!(
32177 apply(&blank_form()).analysis.count_compiler_directives,
32178 "absent count_compiler_directives must default to true"
32179 );
32180 }
32181
32182 #[test]
32183 fn compiler_directives_true_when_enabled() {
32184 let mut form = blank_form();
32185 form.count_compiler_directives = Some("enabled".to_string());
32186 assert!(apply(&form).analysis.count_compiler_directives);
32187 }
32188
32189 #[test]
32190 fn compiler_directives_false_when_disabled() {
32191 let mut form = blank_form();
32192 form.count_compiler_directives = Some("disabled".to_string());
32193 assert!(!apply(&form).analysis.count_compiler_directives);
32194 }
32195
32196 #[test]
32199 fn mixed_policy_unchanged_when_absent() {
32200 assert_eq!(
32202 apply(&blank_form()).analysis.mixed_line_policy,
32203 MixedLinePolicy::CodeOnly
32204 );
32205 }
32206
32207 #[test]
32208 fn mixed_policy_code_only() {
32209 let mut form = blank_form();
32210 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32211 assert_eq!(
32212 apply(&form).analysis.mixed_line_policy,
32213 MixedLinePolicy::CodeOnly
32214 );
32215 }
32216
32217 #[test]
32218 fn mixed_policy_code_and_comment() {
32219 let mut form = blank_form();
32220 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32221 assert_eq!(
32222 apply(&form).analysis.mixed_line_policy,
32223 MixedLinePolicy::CodeAndComment
32224 );
32225 }
32226
32227 #[test]
32228 fn mixed_policy_comment_only() {
32229 let mut form = blank_form();
32230 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32231 assert_eq!(
32232 apply(&form).analysis.mixed_line_policy,
32233 MixedLinePolicy::CommentOnly
32234 );
32235 }
32236
32237 #[test]
32238 fn mixed_policy_separate_mixed_category() {
32239 let mut form = blank_form();
32240 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32241 assert_eq!(
32242 apply(&form).analysis.mixed_line_policy,
32243 MixedLinePolicy::SeparateMixedCategory
32244 );
32245 }
32246
32247 #[test]
32250 fn binary_behavior_skip_when_absent() {
32251 assert_eq!(
32252 apply(&blank_form()).analysis.binary_file_behavior,
32253 BinaryFileBehavior::Skip
32254 );
32255 }
32256
32257 #[test]
32258 fn binary_behavior_skip() {
32259 let mut form = blank_form();
32260 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32261 assert_eq!(
32262 apply(&form).analysis.binary_file_behavior,
32263 BinaryFileBehavior::Skip
32264 );
32265 }
32266
32267 #[test]
32268 fn binary_behavior_fail() {
32269 let mut form = blank_form();
32270 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32271 assert_eq!(
32272 apply(&form).analysis.binary_file_behavior,
32273 BinaryFileBehavior::Fail
32274 );
32275 }
32276
32277 #[test]
32280 fn continuation_policy_each_physical_when_absent() {
32281 assert_eq!(
32282 apply(&blank_form()).analysis.continuation_line_policy,
32283 ContinuationLinePolicy::EachPhysicalLine
32284 );
32285 }
32286
32287 #[test]
32288 fn continuation_policy_collapse_to_logical() {
32289 let mut form = blank_form();
32290 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32291 assert_eq!(
32292 apply(&form).analysis.continuation_line_policy,
32293 ContinuationLinePolicy::CollapseToLogical
32294 );
32295 }
32296
32297 #[test]
32300 fn blank_in_block_comment_count_as_comment_when_absent() {
32301 assert_eq!(
32302 apply(&blank_form()).analysis.blank_in_block_comment_policy,
32303 BlankInBlockCommentPolicy::CountAsComment
32304 );
32305 }
32306
32307 #[test]
32308 fn blank_in_block_comment_count_as_blank() {
32309 let mut form = blank_form();
32310 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32311 assert_eq!(
32312 apply(&form).analysis.blank_in_block_comment_policy,
32313 BlankInBlockCommentPolicy::CountAsBlank
32314 );
32315 }
32316
32317 #[test]
32320 fn style_threshold_80() {
32321 let mut form = blank_form();
32322 form.style_col_threshold = Some("80".to_string());
32323 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32324 }
32325
32326 #[test]
32327 fn style_threshold_100() {
32328 let mut form = blank_form();
32329 form.style_col_threshold = Some("100".to_string());
32330 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32331 }
32332
32333 #[test]
32334 fn style_threshold_120() {
32335 let mut form = blank_form();
32336 form.style_col_threshold = Some("120".to_string());
32337 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32338 }
32339
32340 #[test]
32341 fn style_threshold_invalid_value_leaves_default() {
32342 let mut cfg = sloc_config::AppConfig::default();
32344 let mut form = blank_form();
32345 form.style_col_threshold = Some("42".to_string());
32346 apply_form_to_config(&mut cfg, &form);
32347 assert_eq!(
32348 cfg.analysis.style_col_threshold, 80,
32349 "invalid threshold must not change config"
32350 );
32351 }
32352
32353 #[test]
32354 fn style_threshold_non_numeric_leaves_default() {
32355 let mut cfg = sloc_config::AppConfig::default();
32356 let mut form = blank_form();
32357 form.style_col_threshold = Some("large".to_string());
32358 apply_form_to_config(&mut cfg, &form);
32359 assert_eq!(cfg.analysis.style_col_threshold, 80);
32360 }
32361
32362 #[test]
32363 fn style_threshold_zero_leaves_default() {
32364 let mut cfg = sloc_config::AppConfig::default();
32365 let mut form = blank_form();
32366 form.style_col_threshold = Some("0".to_string());
32367 apply_form_to_config(&mut cfg, &form);
32368 assert_eq!(cfg.analysis.style_col_threshold, 80);
32369 }
32370
32371 #[test]
32372 fn style_threshold_absent_leaves_default() {
32373 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32374 }
32375
32376 #[test]
32379 fn style_score_threshold_zero_when_absent() {
32380 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32381 }
32382
32383 #[test]
32384 fn style_score_threshold_set_to_valid_value() {
32385 let mut form = blank_form();
32386 form.style_score_threshold = Some("70".to_string());
32387 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32388 }
32389
32390 #[test]
32391 fn style_score_threshold_clamps_to_100_when_over() {
32392 let mut form = blank_form();
32394 form.style_score_threshold = Some("200".to_string());
32395 assert_eq!(
32396 apply(&form).analysis.style_score_threshold,
32397 100,
32398 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32399 );
32400 }
32401
32402 #[test]
32405 fn coverage_file_none_when_absent() {
32406 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32407 }
32408
32409 #[test]
32410 fn coverage_file_none_when_whitespace_only() {
32411 let mut form = blank_form();
32412 form.coverage_file = Some(" ".to_string());
32413 assert!(
32414 apply(&form).analysis.coverage_file.is_none(),
32415 "whitespace-only coverage_file must be treated as None"
32416 );
32417 }
32418
32419 #[test]
32420 fn coverage_file_set_when_non_empty() {
32421 let mut form = blank_form();
32422 form.coverage_file = Some("coverage/lcov.info".to_string());
32423 assert_eq!(
32424 apply(&form).analysis.coverage_file,
32425 Some(std::path::PathBuf::from("coverage/lcov.info"))
32426 );
32427 }
32428
32429 #[test]
32430 fn coverage_file_trims_whitespace() {
32431 let mut form = blank_form();
32432 form.coverage_file = Some(" coverage/lcov.info ".to_string());
32433 assert_eq!(
32434 apply(&form).analysis.coverage_file,
32435 Some(std::path::PathBuf::from("coverage/lcov.info"))
32436 );
32437 }
32438
32439 #[test]
32442 fn report_title_unchanged_when_absent() {
32443 let original = sloc_config::AppConfig::default().reporting.report_title;
32444 assert_eq!(apply(&blank_form()).reporting.report_title, original);
32445 }
32446
32447 #[test]
32448 fn report_title_unchanged_when_whitespace_only() {
32449 let original = sloc_config::AppConfig::default().reporting.report_title;
32450 let mut form = blank_form();
32451 form.report_title = Some(" ".to_string());
32452 assert_eq!(
32453 apply(&form).reporting.report_title,
32454 original,
32455 "whitespace-only title must not overwrite the default"
32456 );
32457 }
32458
32459 #[test]
32460 fn report_title_updated_and_trimmed() {
32461 let mut form = blank_form();
32462 form.report_title = Some(" My Project ".to_string());
32463 assert_eq!(apply(&form).reporting.report_title, "My Project");
32464 }
32465
32466 #[test]
32469 fn header_footer_none_when_absent() {
32470 assert!(apply(&blank_form())
32471 .reporting
32472 .report_header_footer
32473 .is_none());
32474 }
32475
32476 #[test]
32477 fn header_footer_none_when_whitespace_only() {
32478 let mut form = blank_form();
32479 form.report_header_footer = Some(" ".to_string());
32480 assert!(apply(&form).reporting.report_header_footer.is_none());
32481 }
32482
32483 #[test]
32484 fn header_footer_set_and_trimmed() {
32485 let mut form = blank_form();
32486 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
32487 assert_eq!(
32488 apply(&form).reporting.report_header_footer,
32489 Some("Confidential — Internal Use".to_string())
32490 );
32491 }
32492
32493 #[test]
32496 fn include_globs_empty_when_absent() {
32497 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32498 }
32499
32500 #[test]
32501 fn include_globs_newline_separated() {
32502 let mut form = blank_form();
32503 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32504 assert_eq!(
32505 apply(&form).discovery.include_globs,
32506 vec!["src/**/*.rs", "tests/**/*.rs"]
32507 );
32508 }
32509
32510 #[test]
32511 fn exclude_globs_comma_separated() {
32512 let mut form = blank_form();
32513 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32514 assert_eq!(
32515 apply(&form).discovery.exclude_globs,
32516 vec!["vendor/**", "node_modules/**"]
32517 );
32518 }
32519
32520 #[test]
32521 fn globs_mixed_separators() {
32522 let mut form = blank_form();
32523 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32524 assert_eq!(
32525 apply(&form).discovery.exclude_globs,
32526 vec!["a/**", "b/**", "c/**"]
32527 );
32528 }
32529
32530 #[test]
32533 fn split_patterns_none_is_empty() {
32534 assert!(split_patterns(None).is_empty());
32535 }
32536
32537 #[test]
32538 fn split_patterns_empty_string_is_empty() {
32539 assert!(split_patterns(Some("")).is_empty());
32540 }
32541
32542 #[test]
32543 fn split_patterns_whitespace_only_is_empty() {
32544 assert!(split_patterns(Some(" \n \n ")).is_empty());
32545 }
32546
32547 #[test]
32548 fn split_patterns_newlines() {
32549 assert_eq!(
32550 split_patterns(Some("a/**\nb/**\nc/**")),
32551 vec!["a/**", "b/**", "c/**"]
32552 );
32553 }
32554
32555 #[test]
32556 fn split_patterns_commas() {
32557 assert_eq!(
32558 split_patterns(Some("a/**,b/**,c/**")),
32559 vec!["a/**", "b/**", "c/**"]
32560 );
32561 }
32562
32563 #[test]
32564 fn split_patterns_mixed() {
32565 assert_eq!(
32566 split_patterns(Some("a/**\nb/**,c/**")),
32567 vec!["a/**", "b/**", "c/**"]
32568 );
32569 }
32570
32571 #[test]
32572 fn split_patterns_trims_whitespace() {
32573 assert_eq!(
32574 split_patterns(Some(" a/** \n b/** ")),
32575 vec!["a/**", "b/**"]
32576 );
32577 }
32578
32579 #[test]
32580 fn split_patterns_filters_empty_entries() {
32581 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32582 }
32583
32584 #[test]
32585 fn split_patterns_single_entry() {
32586 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32587 }
32588}
32589
32590#[cfg(test)]
32591mod utility_tests {
32592 use super::*;
32593 use std::net::IpAddr;
32594 use std::time::Duration;
32595
32596 #[test]
32599 fn sanitize_simple_name() {
32600 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32601 }
32602
32603 #[test]
32604 fn sanitize_uppercased_lowercased() {
32605 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32606 }
32607
32608 #[test]
32609 fn sanitize_path_extracts_filename() {
32610 assert_eq!(
32611 sanitize_project_label("/home/user/my-project"),
32612 "my-project"
32613 );
32614 }
32615
32616 #[test]
32617 fn sanitize_path_uses_last_component() {
32618 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32619 }
32620
32621 #[test]
32622 fn sanitize_spaces_become_hyphens() {
32623 assert_eq!(sanitize_project_label("my project"), "my-project");
32624 }
32625
32626 #[test]
32627 fn sanitize_non_ascii_become_hyphens() {
32628 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32629 }
32630
32631 #[test]
32632 fn sanitize_all_special_chars_gives_project() {
32633 assert_eq!(sanitize_project_label("!@#$%^"), "project");
32634 }
32635
32636 #[test]
32637 fn sanitize_empty_string_gives_project() {
32638 assert_eq!(sanitize_project_label(""), "project");
32639 }
32640
32641 #[test]
32642 fn sanitize_leading_trailing_hyphens_stripped() {
32643 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32644 }
32645
32646 #[test]
32647 fn sanitize_alphanumeric_preserved() {
32648 assert_eq!(sanitize_project_label("repo123"), "repo123");
32649 }
32650
32651 #[test]
32652 fn sanitize_dots_become_hyphens() {
32653 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32654 }
32655
32656 #[test]
32657 fn sanitize_mixed_slashes_uses_filename() {
32658 assert_eq!(sanitize_project_label("project-name"), "project-name");
32660 }
32661
32662 #[test]
32665 fn rate_limiter_allows_first_request() {
32666 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32667 let ip: IpAddr = "127.0.0.1".parse().unwrap();
32668 assert!(rl.is_allowed(ip));
32669 }
32670
32671 #[test]
32672 fn rate_limiter_blocks_after_limit_reached() {
32673 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32674 let ip: IpAddr = "10.0.0.1".parse().unwrap();
32675 assert!(rl.is_allowed(ip));
32676 assert!(rl.is_allowed(ip));
32677 assert!(rl.is_allowed(ip));
32678 assert!(!rl.is_allowed(ip), "4th request must be blocked");
32679 }
32680
32681 #[test]
32682 fn rate_limiter_allows_requests_up_to_limit() {
32683 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32684 let ip: IpAddr = "10.0.0.2".parse().unwrap();
32685 for _ in 0..5 {
32686 assert!(rl.is_allowed(ip));
32687 }
32688 assert!(!rl.is_allowed(ip), "6th request must be blocked");
32689 }
32690
32691 #[test]
32692 fn rate_limiter_different_ips_are_independent() {
32693 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32694 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32695 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32696 assert!(rl.is_allowed(ip1));
32697 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32698 assert!(rl.is_allowed(ip2), "ip2 must be independent");
32699 }
32700
32701 #[test]
32702 fn rate_limiter_auth_failure_not_locked_below_threshold() {
32703 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32704 let ip: IpAddr = "10.0.0.3".parse().unwrap();
32705 rl.record_auth_failure(ip);
32706 rl.record_auth_failure(ip);
32707 assert!(
32708 !rl.is_auth_locked_out(ip),
32709 "not locked at 2 failures when threshold is 3"
32710 );
32711 }
32712
32713 #[test]
32714 fn rate_limiter_auth_failure_locked_at_threshold() {
32715 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32716 let ip: IpAddr = "10.0.0.4".parse().unwrap();
32717 rl.record_auth_failure(ip);
32718 rl.record_auth_failure(ip);
32719 rl.record_auth_failure(ip);
32720 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32721 }
32722
32723 #[test]
32724 fn rate_limiter_auth_failure_different_ips_independent() {
32725 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32726 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32727 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32728 rl.record_auth_failure(ip1);
32729 rl.record_auth_failure(ip1);
32730 assert!(rl.is_auth_locked_out(ip1));
32731 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32732 }
32733
32734 #[test]
32735 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32736 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32737 let ip: IpAddr = "127.0.0.2".parse().unwrap();
32738 for _ in 0..100 {
32739 assert!(rl.is_allowed(ip));
32740 }
32741 }
32742
32743 #[test]
32746 fn strip_unc_plain_path_unchanged() {
32747 let p = PathBuf::from("C:\\Users\\user\\project");
32748 let result = strip_unc_prefix(p.clone());
32749 assert_eq!(result, p);
32750 }
32751
32752 #[test]
32753 fn strip_unc_with_drive_prefix_stripped() {
32754 let p = PathBuf::from(r"\\?\C:\Users\user\project");
32755 let result = strip_unc_prefix(p);
32756 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32757 }
32758
32759 #[test]
32760 fn strip_unc_with_network_prefix_stripped() {
32761 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32762 let result = strip_unc_prefix(p);
32763 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32764 }
32765
32766 #[test]
32767 fn strip_unc_linux_path_unchanged() {
32768 let p = PathBuf::from("/home/user/project");
32769 let result = strip_unc_prefix(p.clone());
32770 assert_eq!(result, p);
32771 }
32772
32773 #[test]
32776 fn remote_to_commit_url_github_https() {
32777 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32778 assert_eq!(
32779 url,
32780 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32781 );
32782 }
32783
32784 #[test]
32785 fn remote_to_commit_url_github_ssh() {
32786 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32787 assert_eq!(
32788 url,
32789 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32790 );
32791 }
32792
32793 #[test]
32794 fn remote_to_commit_url_gitlab_uses_dash_commit() {
32795 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32796 assert_eq!(
32797 url,
32798 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32799 );
32800 }
32801
32802 #[test]
32803 fn remote_to_commit_url_bitbucket_uses_commits() {
32804 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32805 assert_eq!(
32806 url,
32807 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32808 );
32809 }
32810
32811 #[test]
32812 fn remote_to_commit_url_unknown_scheme_returns_none() {
32813 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32814 assert!(url.is_none());
32815 }
32816
32817 #[test]
32818 fn remote_to_commit_url_ssh_gitlab() {
32819 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32820 assert!(url.is_some());
32821 let u = url.unwrap();
32822 assert!(
32823 u.contains("/-/commit/sha123"),
32824 "gitlab ssh must use /-/commit/"
32825 );
32826 }
32827
32828 #[test]
32831 fn git_clone_dest_github_url_produces_safe_name() {
32832 let dir = PathBuf::from("/tmp/clones");
32833 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32834 let name = dest.file_name().unwrap().to_string_lossy();
32835 assert!(!name.is_empty());
32836 assert!(
32837 name.chars()
32838 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32839 "clone dest must only contain safe chars, got: {name}"
32840 );
32841 }
32842
32843 #[test]
32844 fn git_clone_dest_is_inside_clones_dir() {
32845 let dir = PathBuf::from("/tmp/clones");
32846 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32847 assert!(
32848 dest.starts_with(&dir),
32849 "clone dest must be inside clones_dir"
32850 );
32851 }
32852
32853 #[test]
32854 fn git_clone_dest_truncates_to_80_chars_max() {
32855 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32856 let dir = PathBuf::from("/tmp/clones");
32857 let dest = git_clone_dest(&long_url, &dir);
32858 let name = dest.file_name().unwrap().to_string_lossy();
32859 assert!(
32860 name.len() <= 80,
32861 "clone dest name must be at most 80 chars, got {} chars: {name}",
32862 name.len()
32863 );
32864 }
32865
32866 #[test]
32867 fn git_clone_dest_special_chars_replaced_with_underscore() {
32868 let dir = PathBuf::from("/tmp/clones");
32869 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32870 let name = dest.file_name().unwrap().to_string_lossy();
32871 assert!(
32872 !name.contains('@') && !name.contains(':') && !name.contains('/'),
32873 "special chars must be replaced in clone dest, got: {name}"
32874 );
32875 }
32876
32877 #[test]
32878 fn git_clone_dest_different_urls_differ() {
32879 let dir = PathBuf::from("/tmp/clones");
32880 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32881 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32882 assert_ne!(
32883 a, b,
32884 "different repos must produce different clone dest names"
32885 );
32886 }
32887
32888 #[test]
32889 fn git_clone_dest_same_url_same_result() {
32890 let dir = PathBuf::from("/tmp/clones");
32891 let url = "https://github.com/owner/repo.git";
32892 assert_eq!(
32893 git_clone_dest(url, &dir),
32894 git_clone_dest(url, &dir),
32895 "same URL must always give same clone dest"
32896 );
32897 }
32898
32899 #[test]
32902 fn fmt_delta_positive_has_plus_prefix() {
32903 assert_eq!(fmt_delta(5), "+5");
32904 }
32905
32906 #[test]
32907 fn fmt_delta_negative_no_plus_prefix() {
32908 assert_eq!(fmt_delta(-3), "-3");
32909 }
32910
32911 #[test]
32912 fn fmt_delta_zero() {
32913 assert_eq!(fmt_delta(0), "0");
32914 }
32915
32916 #[test]
32919 fn delta_class_positive_is_pos() {
32920 assert_eq!(delta_class(1), "pos");
32921 }
32922
32923 #[test]
32924 fn delta_class_negative_is_neg() {
32925 assert_eq!(delta_class(-1), "neg");
32926 }
32927
32928 #[test]
32929 fn delta_class_zero_is_zero_class() {
32930 assert_eq!(delta_class(0), "zero");
32931 }
32932
32933 #[test]
32936 fn fmt_pct_zero_baseline_returns_em_dash() {
32937 assert_eq!(fmt_pct(100, 0), "\u{2014}");
32938 }
32939
32940 #[test]
32941 fn fmt_pct_positive_delta_has_plus_sign() {
32942 let result = fmt_pct(10, 100);
32943 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32944 }
32945
32946 #[test]
32947 fn fmt_pct_negative_delta_no_plus_sign() {
32948 let result = fmt_pct(-10, 100);
32949 assert!(!result.starts_with('+'), "unexpected + in: {result}");
32950 assert!(result.contains('%'));
32951 }
32952
32953 #[test]
32954 fn fmt_pct_near_zero_returns_pm_zero() {
32955 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32956 }
32957
32958 #[test]
32961 fn summary_delta_no_prev_returns_dash_na() {
32962 let (display, class) = summary_delta(10, None);
32963 assert_eq!(display, "\u{2014}");
32964 assert_eq!(class, "na");
32965 }
32966
32967 #[test]
32968 fn summary_delta_increase_is_positive() {
32969 let (display, class) = summary_delta(15, Some(10));
32970 assert_eq!(display, "+5");
32971 assert_eq!(class, "pos");
32972 }
32973
32974 #[test]
32975 fn summary_delta_decrease_is_negative() {
32976 let (display, class) = summary_delta(5, Some(10));
32977 assert_eq!(display, "-5");
32978 assert_eq!(class, "neg");
32979 }
32980
32981 #[test]
32984 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32985 use chrono::Datelike;
32986 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32987 assert_eq!(d.year(), 2024);
32988 assert_eq!(d.month(), 1);
32989 assert_eq!(d.weekday(), chrono::Weekday::Mon);
32990 assert!(d.day() <= 7);
32991 }
32992
32993 #[test]
32994 fn nth_weekday_second_sunday_march_2024_is_10th() {
32995 use chrono::Datelike;
32996 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32997 assert_eq!(d.weekday(), chrono::Weekday::Sun);
32998 assert_eq!(d.month(), 3);
32999 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33000 }
33001
33002 #[test]
33005 fn is_pacific_dst_july_is_true() {
33006 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33007 assert!(is_pacific_dst(dt), "July must be PDT");
33008 }
33009
33010 #[test]
33011 fn is_pacific_dst_january_is_false() {
33012 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33013 assert!(!is_pacific_dst(dt), "January must be PST");
33014 }
33015
33016 #[test]
33017 fn fmt_la_time_summer_shows_pdt() {
33018 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33019 let result = fmt_la_time(dt);
33020 assert!(
33021 result.ends_with("PDT"),
33022 "summer must use PDT, got: {result}"
33023 );
33024 }
33025
33026 #[test]
33027 fn fmt_la_time_winter_shows_pst() {
33028 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33029 let result = fmt_la_time(dt);
33030 assert!(
33031 result.ends_with("PST"),
33032 "winter must use PST, got: {result}"
33033 );
33034 }
33035
33036 #[test]
33037 fn fmt_la_time_meta_summer_shows_pdt() {
33038 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33039 let result = fmt_la_time_meta(dt);
33040 assert!(
33041 result.ends_with("PDT"),
33042 "meta summer must use PDT, got: {result}"
33043 );
33044 }
33045
33046 #[test]
33047 fn fmt_la_time_meta_winter_shows_pst() {
33048 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33049 let result = fmt_la_time_meta(dt);
33050 assert!(
33051 result.ends_with("PST"),
33052 "meta winter must use PST, got: {result}"
33053 );
33054 }
33055
33056 #[test]
33059 fn fmt_git_date_valid_iso_returns_some() {
33060 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33061 }
33062
33063 #[test]
33064 fn fmt_git_date_invalid_returns_none() {
33065 assert!(fmt_git_date("not-a-date").is_none());
33066 }
33067
33068 #[test]
33071 fn format_number_zero() {
33072 assert_eq!(format_number(0), "0");
33073 }
33074
33075 #[test]
33076 fn format_number_three_digits_no_comma() {
33077 assert_eq!(format_number(999), "999");
33078 }
33079
33080 #[test]
33081 fn format_number_four_digits_has_comma() {
33082 assert_eq!(format_number(1000), "1,000");
33083 }
33084
33085 #[test]
33086 fn format_number_seven_digits_two_commas() {
33087 assert_eq!(format_number(1_234_567), "1,234,567");
33088 }
33089
33090 #[test]
33091 fn format_number_one_million() {
33092 assert_eq!(format_number(1_000_000), "1,000,000");
33093 }
33094
33095 #[test]
33098 fn badge_text_px_empty_is_zero() {
33099 assert_eq!(badge_text_px(""), 0);
33100 }
33101
33102 #[test]
33103 fn badge_text_px_narrow_chars_smaller_than_normal() {
33104 assert!(
33105 badge_text_px("if") < badge_text_px("ab"),
33106 "'if' must be narrower than 'ab'"
33107 );
33108 }
33109
33110 #[test]
33111 fn badge_text_px_m_is_wider_than_a() {
33112 assert!(
33113 badge_text_px("m") > badge_text_px("a"),
33114 "'m' must be wider than 'a'"
33115 );
33116 }
33117
33118 #[test]
33119 fn render_badge_svg_contains_label_and_value() {
33120 let svg = render_badge_svg("coverage", "95%", "#4c1");
33121 assert!(svg.contains("coverage") && svg.contains("95%"));
33122 }
33123
33124 #[test]
33125 fn render_badge_svg_contains_color() {
33126 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33127 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33128 }
33129
33130 #[test]
33131 fn render_badge_svg_escapes_ampersand_in_label() {
33132 let svg = render_badge_svg("test&label", "ok", "#4c1");
33133 assert!(svg.contains("&") && !svg.contains("test&label"));
33134 }
33135
33136 #[test]
33139 fn build_pdf_filename_slugifies_title() {
33140 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33141 assert!(
33142 name.starts_with("my_project_report_")
33143 && std::path::Path::new(&name)
33144 .extension()
33145 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33146 );
33147 }
33148
33149 #[test]
33150 fn build_pdf_filename_uses_last_run_id_segment() {
33151 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33152 assert!(name.contains("ABCD"), "must use last segment of run_id");
33153 }
33154
33155 #[test]
33156 fn build_pdf_filename_empty_title_uses_report_prefix() {
33157 let name = build_pdf_filename("", "abc-def-9999");
33158 assert!(
33159 name.starts_with("report_")
33160 && std::path::Path::new(&name)
33161 .extension()
33162 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33163 );
33164 }
33165
33166 #[test]
33169 fn swap_chart_js_replaces_inline_block() {
33170 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33171 let result = swap_inline_chart_js_for_static(html.to_string());
33172 assert!(result.contains(r#"src="/static/chart-report.js""#));
33173 assert!(!result.contains("inline source"));
33174 }
33175
33176 #[test]
33177 fn swap_chart_js_no_head_returns_unchanged() {
33178 let html = "<body>no head here</body>";
33179 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33180 }
33181
33182 #[test]
33183 fn swap_chart_js_no_script_in_head_unchanged() {
33184 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33185 let result = swap_inline_chart_js_for_static(html.to_string());
33186 assert!(!result.contains("chart-report.js"));
33187 }
33188
33189 #[test]
33192 fn patch_html_nonce_replaces_old_nonce() {
33193 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33194 let result = patch_html_nonce(html, "new-nonce-456");
33195 assert!(result.contains(r#"nonce="new-nonce-456""#));
33196 assert!(!result.contains("old-nonce-123"));
33197 }
33198
33199 #[test]
33200 fn patch_html_nonce_injects_into_bare_style() {
33201 let html = "<style>body{color:red;}</style>";
33202 let result = patch_html_nonce(html, "fresh-nonce");
33203 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33204 }
33205
33206 #[test]
33207 fn patch_html_nonce_injects_into_bare_script() {
33208 let html = "<script>console.log(1);</script>";
33209 let result = patch_html_nonce(html, "abc");
33210 assert!(result.contains(r#"<script nonce="abc">"#));
33211 }
33212
33213 #[test]
33216 fn is_html_report_file_result_html_matches() {
33217 let dir = tempfile::tempdir().unwrap();
33218 let path = dir.path().join("result_20240101.html");
33219 std::fs::write(&path, b"<html></html>").unwrap();
33220 assert!(is_html_report_file(&path));
33221 }
33222
33223 #[test]
33224 fn is_html_report_file_report_html_matches() {
33225 let dir = tempfile::tempdir().unwrap();
33226 let path = dir.path().join("report_abc.html");
33227 std::fs::write(&path, b"<html></html>").unwrap();
33228 assert!(is_html_report_file(&path));
33229 }
33230
33231 #[test]
33232 fn is_html_report_file_index_html_does_not_match() {
33233 let dir = tempfile::tempdir().unwrap();
33234 let path = dir.path().join("index.html");
33235 std::fs::write(&path, b"<html></html>").unwrap();
33236 assert!(!is_html_report_file(&path));
33237 }
33238
33239 #[test]
33240 fn is_html_report_file_nonexistent_returns_false() {
33241 assert!(!is_html_report_file(Path::new(
33242 "/nonexistent/result_xyz.html"
33243 )));
33244 }
33245
33246 #[test]
33247 fn find_html_report_in_dir_finds_result_html() {
33248 let dir = tempfile::tempdir().unwrap();
33249 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33250 assert!(find_html_report_in_dir(dir.path()).is_some());
33251 }
33252
33253 #[test]
33254 fn find_html_report_in_dir_empty_returns_none() {
33255 let dir = tempfile::tempdir().unwrap();
33256 assert!(find_html_report_in_dir(dir.path()).is_none());
33257 }
33258
33259 #[test]
33260 fn find_html_report_in_tree_finds_in_subdir() {
33261 let dir = tempfile::tempdir().unwrap();
33262 let subdir = dir.path().join("run-001");
33263 std::fs::create_dir_all(&subdir).unwrap();
33264 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33265 assert!(find_html_report_in_tree(dir.path()).is_some());
33266 }
33267
33268 #[test]
33271 fn derive_project_label_with_git_repo_and_ref() {
33272 let label = derive_project_label(
33273 Some("https://github.com/owner/my-repo.git"),
33274 Some("main"),
33275 "/fallback/path",
33276 );
33277 assert!(!label.is_empty(), "label must not be empty");
33278 assert!(
33279 label.contains("my") || label.contains("repo"),
33280 "got: {label}"
33281 );
33282 }
33283
33284 #[test]
33285 fn derive_project_label_fallback_to_path() {
33286 let label = derive_project_label(None, None, "/path/to/myproject");
33287 assert_eq!(label, "myproject");
33288 }
33289
33290 #[test]
33291 fn derive_project_label_empty_git_fields_use_path() {
33292 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33293 assert_eq!(label, "cool-app");
33294 }
33295
33296 #[test]
33299 fn derive_file_stem_with_commit_appends_sha() {
33300 assert_eq!(
33301 derive_file_stem("myproject", Some("a1b2c3")),
33302 "myproject_a1b2c3"
33303 );
33304 }
33305
33306 #[test]
33307 fn derive_file_stem_without_commit_returns_label() {
33308 assert_eq!(derive_file_stem("myproject", None), "myproject");
33309 }
33310
33311 #[test]
33312 fn derive_file_stem_empty_commit_returns_label() {
33313 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33314 }
33315
33316 #[test]
33319 fn split_patterns_none_is_empty() {
33320 assert!(split_patterns(None).is_empty());
33321 }
33322
33323 #[test]
33324 fn split_patterns_empty_string_is_empty() {
33325 assert!(split_patterns(Some("")).is_empty());
33326 }
33327
33328 #[test]
33329 fn split_patterns_comma_separated() {
33330 assert_eq!(
33331 split_patterns(Some("foo,bar,baz")),
33332 vec!["foo", "bar", "baz"]
33333 );
33334 }
33335
33336 #[test]
33337 fn split_patterns_newline_separated() {
33338 assert_eq!(
33339 split_patterns(Some("foo\nbar\nbaz")),
33340 vec!["foo", "bar", "baz"]
33341 );
33342 }
33343
33344 #[test]
33345 fn split_patterns_trims_whitespace() {
33346 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
33347 }
33348
33349 #[test]
33352 fn make_git_label_empty_repo_empty_result() {
33353 assert_eq!(make_git_label("", "main"), "");
33354 }
33355
33356 #[test]
33357 fn make_git_label_empty_ref_empty_result() {
33358 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33359 }
33360
33361 #[test]
33362 fn make_git_label_basic_format() {
33363 assert_eq!(
33364 make_git_label("https://github.com/owner/my-repo.git", "main"),
33365 "my-repo_at_main_sloc"
33366 );
33367 }
33368
33369 #[test]
33370 fn make_git_label_slash_in_ref_replaced() {
33371 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33372 assert!(
33373 !label.contains('/'),
33374 "slash in ref must be replaced: {label}"
33375 );
33376 }
33377
33378 #[test]
33381 fn format_dir_size_bytes() {
33382 assert_eq!(format_dir_size(500), "500 B");
33383 }
33384
33385 #[test]
33386 fn format_dir_size_kilobytes() {
33387 assert_eq!(format_dir_size(2048), "2 KB");
33388 }
33389
33390 #[test]
33391 fn format_dir_size_megabytes() {
33392 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33393 }
33394
33395 #[test]
33396 fn format_dir_size_gigabytes() {
33397 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33398 }
33399
33400 #[test]
33401 fn format_dir_size_zero() {
33402 assert_eq!(format_dir_size(0), "0 B");
33403 }
33404
33405 #[test]
33408 fn civil_from_days_epoch() {
33409 assert_eq!(civil_from_days(0), (1970, 1, 1));
33410 }
33411
33412 #[test]
33413 fn civil_from_days_one_year_later() {
33414 assert_eq!(civil_from_days(365), (1971, 1, 1));
33415 }
33416
33417 #[test]
33418 fn civil_from_days_31_days_is_feb_1_1970() {
33419 assert_eq!(civil_from_days(31), (1970, 2, 1));
33420 }
33421
33422 #[test]
33425 fn format_system_time_unix_epoch_formats_correctly() {
33426 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33427 }
33428
33429 #[test]
33430 fn format_system_time_31_days_after_epoch() {
33431 let t = UNIX_EPOCH + Duration::from_hours(744);
33432 assert_eq!(format_system_time(t), "1970-02-01 00:00");
33433 }
33434
33435 #[test]
33436 fn format_system_time_before_epoch_returns_dash() {
33437 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33438 assert_eq!(format_system_time(before), "-");
33439 }
33440 }
33441
33442 #[test]
33445 fn detect_language_name_dot_c() {
33446 assert_eq!(detect_language_name("main.c"), Some("C"));
33447 }
33448
33449 #[test]
33450 fn detect_language_name_dot_h() {
33451 assert_eq!(detect_language_name("defs.h"), Some("C"));
33452 }
33453
33454 #[test]
33455 fn detect_language_name_dot_cpp() {
33456 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33457 }
33458
33459 #[test]
33460 fn detect_language_name_dot_py() {
33461 assert_eq!(detect_language_name("script.py"), Some("Python"));
33462 }
33463
33464 #[test]
33465 fn detect_language_name_dot_ps1() {
33466 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33467 }
33468
33469 #[test]
33470 fn detect_language_name_dot_cs() {
33471 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33472 }
33473
33474 #[test]
33475 fn detect_language_name_dot_sh() {
33476 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33477 }
33478
33479 #[test]
33480 fn detect_language_name_unknown_txt() {
33481 assert_eq!(detect_language_name("notes.txt"), None);
33482 }
33483
33484 #[test]
33487 fn language_icon_file_c() {
33488 assert_eq!(language_icon_file("C"), Some("c.png"));
33489 }
33490
33491 #[test]
33492 fn language_icon_file_python() {
33493 assert_eq!(language_icon_file("Python"), Some("python.png"));
33494 }
33495
33496 #[test]
33497 fn language_icon_file_dockerfile() {
33498 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33499 }
33500
33501 #[test]
33502 fn language_icon_file_rust_is_none() {
33503 assert!(language_icon_file("Rust").is_none());
33504 }
33505
33506 #[test]
33507 fn language_icon_file_unknown_is_none() {
33508 assert!(language_icon_file("Fortran").is_none());
33509 }
33510
33511 #[test]
33514 fn language_inline_svg_rust_is_svg() {
33515 let svg = language_inline_svg("Rust").unwrap();
33516 assert!(svg.starts_with("<svg"));
33517 }
33518
33519 #[test]
33520 fn language_inline_svg_typescript_is_some() {
33521 assert!(language_inline_svg("TypeScript").is_some());
33522 }
33523
33524 #[test]
33525 fn language_inline_svg_unknown_is_none() {
33526 assert!(language_inline_svg("Fortran").is_none());
33527 }
33528
33529 #[test]
33532 fn classify_preview_file_c_supported() {
33533 assert!(matches!(
33534 classify_preview_file("main.c"),
33535 PreviewKind::Supported
33536 ));
33537 }
33538
33539 #[test]
33540 fn classify_preview_file_python_supported() {
33541 assert!(matches!(
33542 classify_preview_file("script.py"),
33543 PreviewKind::Supported
33544 ));
33545 }
33546
33547 #[test]
33548 fn classify_preview_file_png_skipped() {
33549 assert!(matches!(
33550 classify_preview_file("image.png"),
33551 PreviewKind::Skipped
33552 ));
33553 }
33554
33555 #[test]
33556 fn classify_preview_file_zip_skipped() {
33557 assert!(matches!(
33558 classify_preview_file("archive.zip"),
33559 PreviewKind::Skipped
33560 ));
33561 }
33562
33563 #[test]
33564 fn classify_preview_file_min_js_skipped() {
33565 assert!(matches!(
33566 classify_preview_file("bundle.min.js"),
33567 PreviewKind::Skipped
33568 ));
33569 }
33570
33571 #[test]
33572 fn classify_preview_file_rs_unsupported() {
33573 assert!(matches!(
33574 classify_preview_file("main.rs"),
33575 PreviewKind::Unsupported
33576 ));
33577 }
33578
33579 #[test]
33582 fn preview_relative_path_strips_root() {
33583 let root = PathBuf::from("/project");
33584 let path = PathBuf::from("/project/src/main.c");
33585 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33586 }
33587
33588 #[test]
33589 fn preview_relative_path_unrooted_includes_filename() {
33590 let root = PathBuf::from("/other");
33591 let path = PathBuf::from("/project/src/main.c");
33592 let result = preview_relative_path(&root, &path);
33593 assert!(result.contains("main.c"));
33594 }
33595
33596 #[test]
33597 fn preview_relative_path_uses_forward_slashes() {
33598 let root = PathBuf::from("/project");
33599 let path = PathBuf::from("/project/a/b/c.py");
33600 assert!(!preview_relative_path(&root, &path).contains('\\'));
33601 }
33602
33603 #[test]
33606 fn wildcard_match_exact_equal() {
33607 assert!(wildcard_match("foo", "foo"));
33608 }
33609
33610 #[test]
33611 fn wildcard_match_exact_mismatch() {
33612 assert!(!wildcard_match("foo", "bar"));
33613 }
33614
33615 #[test]
33616 fn wildcard_match_star_suffix() {
33617 assert!(wildcard_match("*.rs", "main.rs"));
33618 }
33619
33620 #[test]
33621 fn wildcard_match_star_middle_requires_suffix() {
33622 assert!(!wildcard_match("a*b", "ac"));
33623 }
33624
33625 #[test]
33626 fn wildcard_match_question_mark_single_char() {
33627 assert!(wildcard_match("f?o", "foo"));
33628 }
33629
33630 #[test]
33631 fn wildcard_match_double_star_nested() {
33632 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33633 }
33634
33635 #[test]
33636 fn wildcard_match_star_directory_entry() {
33637 assert!(wildcard_match("vendor/*", "vendor/crate"));
33638 }
33639
33640 #[test]
33641 fn wildcard_match_no_cross_prefix() {
33642 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33643 }
33644
33645 #[test]
33648 fn should_skip_empty_relative_is_false() {
33649 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33650 }
33651
33652 #[test]
33653 fn should_skip_matching_pattern() {
33654 assert!(should_skip_preview_directory(
33655 "vendor",
33656 &["vendor".to_string()]
33657 ));
33658 }
33659
33660 #[test]
33661 fn should_skip_non_matching() {
33662 assert!(!should_skip_preview_directory(
33663 "src",
33664 &["vendor".to_string()]
33665 ));
33666 }
33667
33668 #[test]
33669 fn should_skip_wildcard_prefix() {
33670 assert!(should_skip_preview_directory(
33671 "target/debug",
33672 &["target*".to_string()]
33673 ));
33674 }
33675
33676 #[test]
33679 fn should_include_empty_relative_always_true() {
33680 assert!(should_include_preview_file("", &[], &[]));
33681 }
33682
33683 #[test]
33684 fn should_include_no_patterns_includes_all() {
33685 assert!(should_include_preview_file("src/main.c", &[], &[]));
33686 }
33687
33688 #[test]
33689 fn should_include_excluded_by_pattern() {
33690 assert!(!should_include_preview_file(
33691 "vendor/lib.c",
33692 &[],
33693 &["vendor/*".to_string()]
33694 ));
33695 }
33696
33697 #[test]
33698 fn should_include_include_pattern_filters() {
33699 assert!(!should_include_preview_file(
33700 "tests/test_foo.c",
33701 &["src/*".to_string()],
33702 &[]
33703 ));
33704 }
33705
33706 #[test]
33709 fn escape_html_ampersand() {
33710 assert_eq!(escape_html("a&b"), "a&b");
33711 }
33712
33713 #[test]
33714 fn escape_html_angle_brackets() {
33715 assert_eq!(escape_html("<br>"), "<br>");
33716 }
33717
33718 #[test]
33719 fn escape_html_double_quote() {
33720 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
33721 }
33722
33723 #[test]
33724 fn escape_html_single_quote() {
33725 assert_eq!(escape_html("it's"), "it's");
33726 }
33727
33728 #[test]
33729 fn escape_html_plain_text_unchanged() {
33730 assert_eq!(escape_html("hello world"), "hello world");
33731 }
33732
33733 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33736 sloc_core::ScanComparison {
33737 summary: sloc_core::SummaryDelta {
33738 baseline_run_id: "base".to_string(),
33739 current_run_id: "curr".to_string(),
33740 baseline_timestamp: chrono::Utc::now(),
33741 current_timestamp: chrono::Utc::now(),
33742 baseline_files: 4,
33743 current_files: 4,
33744 files_analyzed_delta: 0,
33745 baseline_code: 330,
33746 current_code: 400,
33747 code_lines_delta: 70,
33748 baseline_comments: 0,
33749 current_comments: 0,
33750 comment_lines_delta: 0,
33751 blank_lines_delta: 0,
33752 total_lines_delta: 70,
33753 coverage_lines_hit_delta: None,
33754 coverage_line_pct_delta: None,
33755 baseline_coverage_line_pct: None,
33756 current_coverage_line_pct: None,
33757 },
33758 file_deltas: vec![
33759 sloc_core::FileDelta {
33760 relative_path: "added.rs".to_string(),
33761 language: Some("Rust".to_string()),
33762 status: FileChangeStatus::Added,
33763 baseline_code: 0,
33764 current_code: 100,
33765 code_delta: 100,
33766 baseline_comment: 0,
33767 current_comment: 0,
33768 comment_delta: 0,
33769 baseline_blank: 0,
33770 current_blank: 0,
33771 blank_delta: 0,
33772 total_delta: 100,
33773 },
33774 sloc_core::FileDelta {
33775 relative_path: "removed.rs".to_string(),
33776 language: Some("Rust".to_string()),
33777 status: FileChangeStatus::Removed,
33778 baseline_code: 50,
33779 current_code: 0,
33780 code_delta: -50,
33781 baseline_comment: 0,
33782 current_comment: 0,
33783 comment_delta: 0,
33784 baseline_blank: 0,
33785 current_blank: 0,
33786 blank_delta: 0,
33787 total_delta: -50,
33788 },
33789 sloc_core::FileDelta {
33790 relative_path: "modified.rs".to_string(),
33791 language: Some("Rust".to_string()),
33792 status: FileChangeStatus::Modified,
33793 baseline_code: 80,
33794 current_code: 100,
33795 code_delta: 20,
33796 baseline_comment: 0,
33797 current_comment: 0,
33798 comment_delta: 0,
33799 baseline_blank: 0,
33800 current_blank: 0,
33801 blank_delta: 0,
33802 total_delta: 20,
33803 },
33804 sloc_core::FileDelta {
33805 relative_path: "unchanged.rs".to_string(),
33806 language: Some("Rust".to_string()),
33807 status: FileChangeStatus::Unchanged,
33808 baseline_code: 200,
33809 current_code: 200,
33810 code_delta: 0,
33811 baseline_comment: 0,
33812 current_comment: 0,
33813 comment_delta: 0,
33814 baseline_blank: 0,
33815 current_blank: 0,
33816 blank_delta: 0,
33817 total_delta: 0,
33818 },
33819 ],
33820 files_added: 1,
33821 files_removed: 1,
33822 files_modified: 1,
33823 files_unchanged: 1,
33824 }
33825 }
33826
33827 #[test]
33828 fn sum_added_counts_added_and_positive_modified() {
33829 let cmp = make_mixed_scan_comparison();
33830 assert_eq!(sum_added_code_lines(&cmp), 120);
33831 }
33832
33833 #[test]
33834 fn sum_removed_counts_removed_baseline() {
33835 let cmp = make_mixed_scan_comparison();
33836 assert_eq!(sum_removed_code_lines(&cmp), 50);
33837 }
33838
33839 #[test]
33840 fn sum_unmodified_counts_unchanged_files() {
33841 let cmp = make_mixed_scan_comparison();
33842 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33843 }
33844
33845 #[test]
33848 fn detect_coverage_tool_rust_project() {
33849 let dir = tempfile::tempdir().unwrap();
33850 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33851 let (tool, cmd) = detect_coverage_tool(dir.path());
33852 assert_eq!(tool, Some("cargo-llvm-cov"));
33853 assert!(cmd.is_some());
33854 }
33855
33856 #[test]
33857 fn detect_coverage_tool_java_gradle() {
33858 let dir = tempfile::tempdir().unwrap();
33859 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33860 let (tool, _) = detect_coverage_tool(dir.path());
33861 assert_eq!(tool, Some("jacoco"));
33862 }
33863
33864 #[test]
33865 fn detect_coverage_tool_python_pyproject() {
33866 let dir = tempfile::tempdir().unwrap();
33867 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33868 let (tool, _) = detect_coverage_tool(dir.path());
33869 assert_eq!(tool, Some("pytest-cov"));
33870 }
33871
33872 #[test]
33873 fn detect_coverage_tool_unknown_project() {
33874 let dir = tempfile::tempdir().unwrap();
33875 let (tool, cmd) = detect_coverage_tool(dir.path());
33876 assert!(tool.is_none() && cmd.is_none());
33877 }
33878
33879 #[test]
33882 fn sanitize_path_str_unc_drive_stripped() {
33883 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33884 }
33885
33886 #[test]
33887 fn sanitize_path_str_unc_network_stripped() {
33888 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33889 }
33890
33891 #[test]
33892 fn sanitize_path_str_plain_path_unchanged() {
33893 assert_eq!(
33894 sanitize_path_str("/home/user/project"),
33895 "/home/user/project"
33896 );
33897 }
33898
33899 #[test]
33900 fn display_path_plain_linux_unchanged() {
33901 assert_eq!(
33902 display_path(Path::new("/home/user/project")),
33903 "/home/user/project"
33904 );
33905 }
33906
33907 #[test]
33908 fn display_path_unc_drive_stripped() {
33909 let result = display_path(Path::new(r"\\?\C:\Users\user"));
33910 assert_eq!(result, r"C:\Users\user");
33911 }
33912
33913 #[test]
33914 fn display_path_unc_network_stripped() {
33915 let result = display_path(Path::new(r"\\?\UNC\server\share"));
33916 assert_eq!(result, r"\\server\share");
33917 }
33918}
33919
33920#[cfg(test)]
33921mod coverage_boost_unit_tests {
33922 use super::*;
33923 use std::path::{Path, PathBuf};
33924
33925 #[tokio::test]
33929 async fn runtime_security_config_scenarios() {
33930 std::env::remove_var("SLOC_API_KEYS");
33931 std::env::remove_var("SLOC_API_KEY");
33932 std::env::remove_var("SLOC_TLS_CERT");
33933 std::env::remove_var("SLOC_TLS_KEY");
33934 std::env::remove_var("SLOC_TRUST_PROXY");
33935 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33936 let cfg = load_runtime_security_config(false);
33937 assert!(cfg.api_keys.is_empty());
33938 assert!(!cfg.tls_enabled);
33939 assert!(!cfg.trust_proxy);
33940
33941 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33942 std::env::set_var("SLOC_TRUST_PROXY", "1");
33943 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33944 std::env::set_var("SLOC_RATE_LIMIT", "250");
33945 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33946 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33947 let cfg = load_runtime_security_config(true);
33948 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33949 assert!(cfg.trust_proxy);
33950 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33951 std::env::remove_var("SLOC_API_KEYS");
33952 std::env::remove_var("SLOC_TRUST_PROXY");
33953 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33954 std::env::remove_var("SLOC_RATE_LIMIT");
33955 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33956 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33957 }
33958
33959 #[test]
33960 fn cors_layer_builds_both_modes() {
33961 let _ = build_cors_layer(true);
33962 let _ = build_cors_layer(false);
33963 }
33964
33965 #[test]
33966 fn primary_lan_ip_callable() {
33967 let _ = primary_lan_ip();
33969 }
33970
33971 #[test]
33972 fn safe_redirect_allows_relative_rejects_absolute() {
33973 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33974 assert_eq!(safe_redirect("https://evil.example/x"), "/");
33975 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33976 assert_eq!(default_redirect(), "/view-reports");
33977 }
33978
33979 #[test]
33980 fn tarball_size_caps_env_override() {
33981 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33982 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33983 let (c, d) = parse_tarball_size_caps();
33984 assert_eq!(c, 1024 * 1024);
33985 assert_eq!(d, 2 * 1024 * 1024);
33986 std::env::remove_var("SLOC_MAX_TARBALL_MB");
33987 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33988 let (c2, _) = parse_tarball_size_caps();
33989 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33990 }
33991
33992 #[test]
33993 fn upload_path_helpers() {
33994 let base = upload_base_dir();
33995 let staged = upload_staging_path("abc123");
33996 assert!(staged.starts_with(&base));
33997 assert!(
33998 is_upload_tmp_path(&staged),
33999 "staging path is an upload tmp path"
34000 );
34001 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34002 }
34003
34004 #[test]
34005 fn git_clones_dir_env_override() {
34006 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34007 let def = resolve_git_clones_dir(Path::new("/out"));
34008 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34009 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34010 assert_eq!(
34011 resolve_git_clones_dir(Path::new("/out")),
34012 PathBuf::from("/custom/clones")
34013 );
34014 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34015 }
34016
34017 #[test]
34018 fn html_report_file_detection() {
34019 let dir = std::env::temp_dir().join("sloc_html_detect");
34020 let _ = std::fs::create_dir_all(&dir);
34021 let good = dir.join("report_x.html");
34022 std::fs::write(&good, "<html></html>").unwrap();
34023 let bad = dir.join("notes.txt");
34024 std::fs::write(&bad, "x").unwrap();
34025 assert!(is_html_report_file(&good));
34026 assert!(!is_html_report_file(&bad));
34027 assert!(find_html_report_in_dir(&dir).is_some());
34028 let _ = std::fs::remove_dir_all(&dir);
34029 }
34030
34031 #[test]
34032 fn multi_delta_class_and_format() {
34033 assert_eq!(multi_delta_class(5), "pos");
34034 assert_eq!(multi_delta_class(-5), "neg");
34035 assert_eq!(multi_delta_class(0), "zero");
34036 assert_eq!(multi_fmt_delta(3), "+3");
34037 assert_eq!(multi_fmt_delta(-3), "-3");
34038 assert_eq!(multi_fmt_delta(0), "0");
34039 }
34040
34041 #[test]
34042 fn git_clone_dest_sanitizes() {
34043 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34044 assert!(dest.starts_with("/clones"));
34045 let name = dest.file_name().unwrap().to_str().unwrap();
34046 assert!(name
34047 .chars()
34048 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34049 }
34050}
34051
34052#[cfg(test)]
34053mod tests_private {
34054 use super::*;
34055 use std::io::Read;
34056
34057 #[test]
34058 fn size_limit_reader_zero_remaining_returns_error() {
34059 let data = b"hello world";
34060 let mut reader = SizeLimitReader {
34061 inner: &data[..],
34062 remaining: 0,
34063 };
34064 let mut buf = [0u8; 4];
34065 assert!(reader.read(&mut buf).is_err());
34066 }
34067
34068 #[test]
34069 fn size_limit_reader_counts_bytes() {
34070 let data = b"hello world";
34071 let mut reader = SizeLimitReader {
34072 inner: &data[..],
34073 remaining: 5,
34074 };
34075 let mut buf = [0u8; 4];
34076 let n = reader.read(&mut buf).unwrap();
34077 assert_eq!(n, 4);
34078 assert_eq!(reader.remaining, 1);
34079 }
34080
34081 #[test]
34082 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34083 let uuid = "12345678-1234-1234-1234-123456789012";
34084 let (id, path) = resolve_or_create_staging(Some(uuid));
34085 assert_eq!(id, uuid);
34086 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34087 }
34088
34089 #[test]
34090 fn resolve_or_create_staging_with_none_creates_new() {
34091 let (id1, _) = resolve_or_create_staging(None);
34092 let (id2, _) = resolve_or_create_staging(None);
34093 assert_ne!(id1, id2);
34094 }
34095
34096 #[test]
34097 fn resolve_or_create_staging_with_path_separator_creates_new() {
34098 let (id, _) = resolve_or_create_staging(Some("has/slash"));
34100 assert_ne!(id, "has/slash");
34101 }
34102
34103 #[test]
34104 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34105 use std::net::IpAddr;
34106 use std::str::FromStr;
34107 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34108 let ip = IpAddr::from_str("192.168.1.1").unwrap();
34109 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34110 }
34111
34112 #[test]
34113 fn is_auth_locked_out_expired_entry_removed() {
34114 use std::net::IpAddr;
34115 use std::str::FromStr;
34116 let limiter = IpRateLimiter::new(
34117 Duration::from_mins(1),
34118 100,
34119 1, Duration::from_millis(1),
34121 );
34122 let ip = IpAddr::from_str("192.168.1.2").unwrap();
34123 limiter.record_auth_failure(ip);
34124 std::thread::sleep(Duration::from_millis(10));
34126 assert!(!limiter.is_auth_locked_out(ip));
34128 }
34129
34130 #[test]
34131 fn is_auth_locked_out_within_window_returns_true() {
34132 use std::net::IpAddr;
34133 use std::str::FromStr;
34134 let limiter = IpRateLimiter::new(
34135 Duration::from_mins(1),
34136 100,
34137 2, Duration::from_hours(1),
34139 );
34140 let ip = IpAddr::from_str("192.168.1.3").unwrap();
34141 limiter.record_auth_failure(ip);
34142 limiter.record_auth_failure(ip);
34143 assert!(limiter.is_auth_locked_out(ip));
34144 }
34145
34146 #[test]
34149 fn output_folder_hint_strips_json_subdir() {
34150 use std::path::Path;
34151 let path = Path::new("/output/scan1/json/result.json");
34152 let hint = output_folder_hint(path);
34153 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34154 }
34155
34156 #[test]
34157 fn output_folder_hint_strips_html_subdir() {
34158 use std::path::Path;
34159 let path = Path::new("/output/scan1/html/report.html");
34160 let hint = output_folder_hint(path);
34161 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34162 }
34163
34164 #[test]
34165 fn output_folder_hint_strips_pdf_subdir() {
34166 use std::path::Path;
34167 let path = Path::new("/output/scan1/pdf/report.pdf");
34168 let hint = output_folder_hint(path);
34169 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34170 }
34171
34172 #[test]
34173 fn output_folder_hint_strips_excel_subdir() {
34174 use std::path::Path;
34175 let path = Path::new("/output/scan1/excel/report.xlsx");
34176 let hint = output_folder_hint(path);
34177 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34178 }
34179
34180 #[test]
34181 fn output_folder_hint_flat_layout_returns_direct_parent() {
34182 use std::path::Path;
34183 let path = Path::new("/output/scan1/result.json");
34184 let hint = output_folder_hint(path);
34185 assert!(
34186 hint.ends_with("scan1"),
34187 "expected direct parent, got: {hint}"
34188 );
34189 }
34190
34191 #[test]
34192 fn output_folder_hint_other_subdir_name_not_stripped() {
34193 use std::path::Path;
34194 let path = Path::new("/output/scan1/data/result.json");
34196 let hint = output_folder_hint(path);
34197 assert!(
34198 hint.ends_with("data"),
34199 "non-artifact subdir must not be stripped, got: {hint}"
34200 );
34201 }
34202
34203 #[test]
34206 fn find_file_by_ext_finds_matching_file() {
34207 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34208 let _ = fs::create_dir_all(&dir);
34209 let f = dir.join("report.pdf");
34210 let _ = fs::write(&f, b"dummy");
34211 let result = find_file_by_ext(&dir, "pdf");
34212 assert!(result.is_some(), "expected to find report.pdf");
34213 let _ = fs::remove_dir_all(&dir);
34214 }
34215
34216 #[test]
34217 fn find_file_by_ext_returns_none_for_missing_ext() {
34218 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34219 let _ = fs::create_dir_all(&dir);
34220 let f = dir.join("report.json");
34221 let _ = fs::write(&f, b"{}");
34222 let result = find_file_by_ext(&dir, "pdf");
34223 assert!(result.is_none());
34224 let _ = fs::remove_dir_all(&dir);
34225 }
34226
34227 #[test]
34228 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34229 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34230 assert!(find_file_by_ext(dir, "json").is_none());
34231 }
34232
34233 #[test]
34236 fn collect_result_json_candidates_flat_root() {
34237 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34238 let _ = fs::create_dir_all(&root);
34239 let _ = fs::write(root.join("result.json"), b"{}");
34240 let candidates = collect_result_json_candidates(&root);
34241 assert!(!candidates.is_empty(), "should find result.json at root");
34242 let _ = fs::remove_dir_all(&root);
34243 }
34244
34245 #[test]
34246 fn collect_result_json_candidates_legacy_subdir() {
34247 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34248 let sub = root.join("scanA");
34249 let _ = fs::create_dir_all(&sub);
34250 let _ = fs::write(sub.join("result.json"), b"{}");
34251 let candidates = collect_result_json_candidates(&root);
34252 assert!(
34253 !candidates.is_empty(),
34254 "should find result.json in legacy subdir"
34255 );
34256 let _ = fs::remove_dir_all(&root);
34257 }
34258
34259 #[test]
34260 fn collect_result_json_candidates_structured_json_subdir() {
34261 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34262 let json_sub = root.join("scanB").join("json");
34263 let _ = fs::create_dir_all(&json_sub);
34264 let _ = fs::write(json_sub.join("result.json"), b"{}");
34265 let candidates = collect_result_json_candidates(&root);
34266 assert!(
34267 !candidates.is_empty(),
34268 "should find result.json inside <subdir>/json/"
34269 );
34270 let _ = fs::remove_dir_all(&root);
34271 }
34272
34273 #[test]
34274 fn collect_result_json_candidates_empty_dir() {
34275 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34276 let _ = fs::create_dir_all(&root);
34277 let candidates = collect_result_json_candidates(&root);
34278 assert!(candidates.is_empty());
34279 let _ = fs::remove_dir_all(&root);
34280 }
34281}