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 \u2014 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 \u2014 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_language_pills_row(languages: &[&str], out: &mut String) {
16586 use std::fmt::Write as _;
16587 if languages.is_empty() {
16588 out.push_str(
16589 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16590 );
16591 return;
16592 }
16593 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16594 for language in languages {
16595 if let Some(icon) = language_icon_file(language) {
16596 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();
16597 } else if let Some(svg) = language_inline_svg(language) {
16598 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();
16599 } else {
16600 write!(
16601 out,
16602 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16603 escape_html(&language.to_ascii_lowercase()),
16604 escape_html(language)
16605 )
16606 .ok();
16607 }
16608 }
16609}
16610
16611#[allow(clippy::too_many_lines)]
16612fn build_preview_html(
16613 root: &Path,
16614 include_patterns: &[String],
16615 exclude_patterns: &[String],
16616) -> Result<String> {
16617 if !root.exists() {
16618 return Ok(format!(
16619 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16620 escape_html(&display_path(root))
16621 ));
16622 }
16623
16624 let _selected = display_path(root);
16625 let mut stats = PreviewStats::default();
16626 let mut rows = Vec::new();
16627 let mut languages = Vec::new();
16628 let mut budget = PreviewBudget {
16629 shown: 0,
16630 max_entries: 600,
16631 max_depth: 9,
16632 };
16633 let mut next_row_id = 1usize;
16634
16635 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16636 || root.to_string_lossy().into_owned(),
16637 std::string::ToString::to_string,
16638 );
16639 let root_modified = root
16640 .metadata()
16641 .ok()
16642 .and_then(|meta| meta.modified().ok())
16643 .map_or_else(|| "-".to_string(), format_system_time);
16644
16645 rows.push(PreviewRow {
16646 row_id: 0,
16647 parent_row_id: None,
16648 depth: 0,
16649 name: format!("{root_name}/"),
16650 kind: PreviewKind::Dir,
16651 is_dir: true,
16652 language: None,
16653 modified: root_modified,
16654 type_label: "Directory".to_string(),
16655 });
16656 collect_preview_rows(
16657 root,
16658 root,
16659 0,
16660 Some(0),
16661 &mut next_row_id,
16662 &mut budget,
16663 &mut stats,
16664 &mut rows,
16665 &mut languages,
16666 include_patterns,
16667 exclude_patterns,
16668 )?;
16669
16670 let root_size = format_dir_size(dir_size_bytes(root));
16671
16672 let mut out = String::new();
16673 write!(
16674 out,
16675 r#"<div class="explorer-wrap" data-project-size="{}">"#,
16676 escape_html(&root_size)
16677 )
16678 .ok();
16679 out.push_str(r#"<div class="explorer-toolbar compact">"#);
16680 out.push_str(r#"<div class="explorer-title-group">"#);
16681 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16682 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16683 out.push_str(r"</div></div>");
16684
16685 out.push_str(r#"<div class="scope-stats">"#);
16686 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();
16687 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();
16688 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();
16689 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();
16690 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();
16691 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>"#);
16692 out.push_str(r"</div>");
16693
16694 let submodules = sloc_core::detect_submodules(root);
16695 if !submodules.is_empty() {
16696 render_submodule_chips(root, &submodules, &mut out);
16697 }
16698
16699 out.push_str(r#"<div class="scope-info-row">"#);
16700 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16701 render_language_pills_row(&languages, &mut out);
16702 out.push_str(r"</div></div>");
16703 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>"#);
16704 out.push_str(r"</div>");
16705
16706 out.push_str(r#"<div class="file-explorer-shell">"#);
16707 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>"#);
16708 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>"#);
16709 out.push_str(r#"<div class="file-explorer-tree">"#);
16710 for row in rows {
16711 let status_label = row.kind.label();
16712 let lang_attr = row.language.unwrap_or("");
16713 let toggle_html = if row.is_dir {
16714 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16715 .to_string()
16716 } else {
16717 r#"<span class="tree-bullet">•</span>"#.to_string()
16718 };
16719 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();
16720 }
16721 if budget.shown >= budget.max_entries {
16722 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>"#);
16723 }
16724 out.push_str(r"</div></div></div>");
16725
16726 Ok(out)
16727}
16728
16729#[derive(Default)]
16730struct PreviewStats {
16731 directories: usize,
16732 files: usize,
16733 supported: usize,
16734 skipped: usize,
16735 unsupported: usize,
16736}
16737
16738struct PreviewRow {
16739 row_id: usize,
16740 parent_row_id: Option<usize>,
16741 depth: usize,
16742 name: String,
16743 kind: PreviewKind,
16744 is_dir: bool,
16745 language: Option<&'static str>,
16746 modified: String,
16747 type_label: String,
16748}
16749
16750#[derive(Copy, Clone)]
16751enum PreviewKind {
16752 Dir,
16753 Supported,
16754 Skipped,
16755 Unsupported,
16756}
16757
16758impl PreviewKind {
16759 const fn filter_key(self) -> &'static str {
16760 match self {
16761 Self::Dir => "dir",
16762 Self::Supported => "supported",
16763 Self::Skipped => "skipped",
16764 Self::Unsupported => "unsupported",
16765 }
16766 }
16767
16768 const fn label(self) -> &'static str {
16769 match self {
16770 Self::Dir => "dir",
16771 Self::Supported => "supported",
16772 Self::Skipped => "skipped by policy",
16773 Self::Unsupported => "unsupported",
16774 }
16775 }
16776
16777 const fn badge_class(self) -> &'static str {
16778 match self {
16779 Self::Dir => "badge badge-dir",
16780 Self::Supported => "badge badge-scan",
16781 Self::Skipped => "badge badge-skip",
16782 Self::Unsupported => "badge badge-unsupported",
16783 }
16784 }
16785
16786 const fn node_class(self) -> &'static str {
16787 match self {
16788 Self::Dir => "tree-node-dir",
16789 Self::Supported => "tree-node-supported",
16790 Self::Skipped => "tree-node-skipped",
16791 Self::Unsupported => "tree-node-unsupported",
16792 }
16793 }
16794}
16795
16796struct PreviewBudget {
16797 shown: usize,
16798 max_entries: usize,
16799 max_depth: usize,
16800}
16801
16802#[allow(clippy::too_many_arguments)]
16805fn handle_preview_dir_entry(
16806 root: &Path,
16807 path: &Path,
16808 name: &str,
16809 modified: String,
16810 depth: usize,
16811 parent_row_id: Option<usize>,
16812 row_id: usize,
16813 next_row_id: &mut usize,
16814 budget: &mut PreviewBudget,
16815 stats: &mut PreviewStats,
16816 rows: &mut Vec<PreviewRow>,
16817 languages: &mut Vec<&'static str>,
16818 include_patterns: &[String],
16819 exclude_patterns: &[String],
16820) -> Result<()> {
16821 let relative = preview_relative_path(root, path);
16822 if should_skip_preview_directory(&relative, exclude_patterns) {
16823 return Ok(());
16824 }
16825 stats.directories += 1;
16826 rows.push(PreviewRow {
16827 row_id,
16828 parent_row_id,
16829 depth: depth + 1,
16830 name: format!("{name}/"),
16831 kind: PreviewKind::Dir,
16832 is_dir: true,
16833 language: None,
16834 modified,
16835 type_label: "Directory".to_string(),
16836 });
16837 budget.shown += 1;
16838 if !matches!(name, ".git" | "node_modules" | "target") {
16839 collect_preview_rows(
16840 root,
16841 path,
16842 depth + 1,
16843 Some(row_id),
16844 next_row_id,
16845 budget,
16846 stats,
16847 rows,
16848 languages,
16849 include_patterns,
16850 exclude_patterns,
16851 )?;
16852 }
16853 Ok(())
16854}
16855
16856#[allow(clippy::too_many_arguments)]
16858fn handle_preview_file_entry(
16859 root: &Path,
16860 path: &Path,
16861 name: &str,
16862 modified: String,
16863 depth: usize,
16864 parent_row_id: Option<usize>,
16865 row_id: usize,
16866 budget: &mut PreviewBudget,
16867 stats: &mut PreviewStats,
16868 rows: &mut Vec<PreviewRow>,
16869 languages: &mut Vec<&'static str>,
16870 include_patterns: &[String],
16871 exclude_patterns: &[String],
16872) {
16873 let relative = preview_relative_path(root, path);
16874 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16875 return;
16876 }
16877 stats.files += 1;
16878 let kind = classify_preview_file(name);
16879 match kind {
16880 PreviewKind::Supported => stats.supported += 1,
16881 PreviewKind::Skipped => stats.skipped += 1,
16882 PreviewKind::Unsupported => stats.unsupported += 1,
16883 PreviewKind::Dir => {}
16884 }
16885 let language = detect_language_name(name);
16886 if let Some(lang) = language {
16887 if !languages.contains(&lang) {
16888 languages.push(lang);
16889 }
16890 }
16891 rows.push(PreviewRow {
16892 row_id,
16893 parent_row_id,
16894 depth: depth + 1,
16895 name: name.to_owned(),
16896 kind,
16897 is_dir: false,
16898 language,
16899 modified,
16900 type_label: preview_type_label(name, language, kind),
16901 });
16902 budget.shown += 1;
16903}
16904
16905#[allow(clippy::too_many_arguments)]
16906#[allow(clippy::too_many_lines)]
16907fn collect_preview_rows(
16908 root: &Path,
16909 dir: &Path,
16910 depth: usize,
16911 parent_row_id: Option<usize>,
16912 next_row_id: &mut usize,
16913 budget: &mut PreviewBudget,
16914 stats: &mut PreviewStats,
16915 rows: &mut Vec<PreviewRow>,
16916 languages: &mut Vec<&'static str>,
16917 include_patterns: &[String],
16918 exclude_patterns: &[String],
16919) -> Result<()> {
16920 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16921 return Ok(());
16922 }
16923
16924 let mut entries = fs::read_dir(dir)
16925 .with_context(|| format!("failed to read directory {}", dir.display()))?
16926 .filter_map(std::result::Result::ok)
16927 .collect::<Vec<_>>();
16928 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16929
16930 for entry in entries {
16931 if budget.shown >= budget.max_entries {
16932 break;
16933 }
16934
16935 let path = entry.path();
16936 let name = entry.file_name().to_string_lossy().into_owned();
16937 let Ok(metadata) = entry.metadata() else {
16938 continue;
16939 };
16940 let row_id = *next_row_id;
16941 *next_row_id += 1;
16942 let modified = metadata
16943 .modified()
16944 .ok()
16945 .map_or_else(|| "-".to_string(), format_system_time);
16946
16947 if metadata.is_dir() {
16948 handle_preview_dir_entry(
16949 root,
16950 &path,
16951 &name,
16952 modified,
16953 depth,
16954 parent_row_id,
16955 row_id,
16956 next_row_id,
16957 budget,
16958 stats,
16959 rows,
16960 languages,
16961 include_patterns,
16962 exclude_patterns,
16963 )?;
16964 continue;
16965 }
16966
16967 if metadata.is_file() {
16968 handle_preview_file_entry(
16969 root,
16970 &path,
16971 &name,
16972 modified,
16973 depth,
16974 parent_row_id,
16975 row_id,
16976 budget,
16977 stats,
16978 rows,
16979 languages,
16980 include_patterns,
16981 exclude_patterns,
16982 );
16983 }
16984 }
16985
16986 Ok(())
16987}
16988
16989fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
16990 if let Some(language) = language {
16991 return format!("{language} source");
16992 }
16993 let lower = name.to_ascii_lowercase();
16994 let ext = Path::new(&lower)
16995 .extension()
16996 .and_then(|e| e.to_str())
16997 .unwrap_or("");
16998 match kind {
16999 PreviewKind::Skipped => {
17000 if lower.ends_with(".min.js") {
17001 "Minified asset".to_string()
17002 } else if [
17003 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17004 ]
17005 .contains(&ext)
17006 {
17007 "Binary or archive".to_string()
17008 } else {
17009 "Skipped file".to_string()
17010 }
17011 }
17012 PreviewKind::Unsupported => {
17013 if ext.is_empty() {
17014 "Unsupported file".to_string()
17015 } else {
17016 format!("{} file", ext.to_ascii_uppercase())
17017 }
17018 }
17019 PreviewKind::Supported => "Supported source".to_string(),
17020 PreviewKind::Dir => "Directory".to_string(),
17021 }
17022}
17023
17024fn format_system_time(time: SystemTime) -> String {
17025 #[allow(clippy::cast_possible_wrap)]
17026 let secs = match time.duration_since(UNIX_EPOCH) {
17027 Ok(duration) => duration.as_secs() as i64,
17028 Err(_) => return "-".to_string(),
17029 };
17030 let days = secs.div_euclid(86_400);
17031 let secs_of_day = secs.rem_euclid(86_400);
17032 let (year, month, day) = civil_from_days(days);
17033 let hour = secs_of_day / 3_600;
17034 let minute = (secs_of_day % 3_600) / 60;
17035 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17036}
17037
17038#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17039fn civil_from_days(days: i64) -> (i32, u32, u32) {
17040 let z = days + 719_468;
17041 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17042 let doe = z - era * 146_097;
17043 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17044 let y = yoe + era * 400;
17045 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17046 let mp = (5 * doy + 2) / 153;
17047 let d = doy - (153 * mp + 2) / 5 + 1;
17048 let m = mp + if mp < 10 { 3 } else { -9 };
17049 let year = y + i64::from(m <= 2);
17050 (year as i32, m as u32, d as u32)
17051}
17052
17053#[allow(clippy::case_sensitive_file_extension_comparisons)]
17056fn detect_language_name(name: &str) -> Option<&'static str> {
17057 let lower = name.to_ascii_lowercase();
17058 if lower.ends_with(".c") || lower.ends_with(".h") {
17059 Some("C")
17060 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17061 .iter()
17062 .any(|s| lower.ends_with(s))
17063 {
17064 Some("C++")
17065 } else if lower.ends_with(".cs") {
17066 Some("C#")
17067 } else if lower.ends_with(".py") {
17068 Some("Python")
17069 } else if lower.ends_with(".sh") {
17070 Some("Shell")
17071 } else if [".ps1", ".psm1", ".psd1"]
17072 .iter()
17073 .any(|s| lower.ends_with(s))
17074 {
17075 Some("PowerShell")
17076 } else {
17077 None
17078 }
17079}
17080
17081fn language_icon_file(language: &str) -> Option<&'static str> {
17082 match language {
17083 "C" => Some("c.png"),
17084 "C++" => Some("cpp.png"),
17085 "C#" => Some("c-sharp.png"),
17086 "Python" => Some("python.png"),
17087 "Shell" => Some("shell.png"),
17088 "PowerShell" => Some("powershell.png"),
17089 "JavaScript" => Some("java-script.png"),
17090 "HTML" => Some("html-5.png"),
17091 "Java" => Some("java.png"),
17092 "Visual Basic" => Some("visual-basic.png"),
17093 "Assembly" => Some("asm.png"),
17094 "Go" => Some("go.png"),
17095 "R" => Some("r.png"),
17096 "XML" => Some("xml.png"),
17097 "Groovy" => Some("groovy.png"),
17098 "Dockerfile" => Some("docker.png"),
17099 "Makefile" => Some("makefile.svg"),
17100 "Perl" => Some("perl.svg"),
17101 _ => None,
17102 }
17103}
17104
17105fn language_inline_svg(language: &str) -> Option<&'static str> {
17110 match language {
17111 "Rust" => Some(
17112 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>"##,
17113 ),
17114 "TypeScript" => Some(
17115 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>"##,
17116 ),
17117 _ => None,
17118 }
17119}
17120
17121#[allow(clippy::case_sensitive_file_extension_comparisons)]
17124fn classify_preview_file(name: &str) -> PreviewKind {
17125 let lower = name.to_ascii_lowercase();
17126
17127 let scannable = [
17128 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17129 ".psm1", ".psd1",
17130 ]
17131 .iter()
17132 .any(|suffix| lower.ends_with(suffix));
17133
17134 if scannable {
17135 PreviewKind::Supported
17136 } else if lower.ends_with(".min.js")
17137 || lower.ends_with(".lock")
17138 || lower.ends_with(".png")
17139 || lower.ends_with(".jpg")
17140 || lower.ends_with(".jpeg")
17141 || lower.ends_with(".gif")
17142 || lower.ends_with(".zip")
17143 || lower.ends_with(".pdf")
17144 || lower.ends_with(".pyc")
17145 || lower.ends_with(".xz")
17146 || lower.ends_with(".tar")
17147 || lower.ends_with(".gz")
17148 {
17149 PreviewKind::Skipped
17150 } else {
17151 PreviewKind::Unsupported
17152 }
17153}
17154
17155fn preview_relative_path(root: &Path, path: &Path) -> String {
17156 path.strip_prefix(root)
17157 .ok()
17158 .unwrap_or(path)
17159 .to_string_lossy()
17160 .replace('\\', "/")
17161 .trim_matches('/')
17162 .to_string()
17163}
17164
17165fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17166 if relative.is_empty() {
17167 return false;
17168 }
17169
17170 exclude_patterns.iter().any(|pattern| {
17171 wildcard_match(pattern, relative)
17172 || wildcard_match(pattern, &format!("{relative}/"))
17173 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17174 })
17175}
17176
17177fn should_include_preview_file(
17178 relative: &str,
17179 include_patterns: &[String],
17180 exclude_patterns: &[String],
17181) -> bool {
17182 if relative.is_empty() {
17183 return true;
17184 }
17185
17186 let included = include_patterns.is_empty()
17187 || include_patterns
17188 .iter()
17189 .any(|pattern| wildcard_match(pattern, relative));
17190 let excluded = exclude_patterns
17191 .iter()
17192 .any(|pattern| wildcard_match(pattern, relative));
17193
17194 included && !excluded
17195}
17196
17197fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17198 let pattern = pattern.trim().replace('\\', "/");
17199 let candidate = candidate.trim().replace('\\', "/");
17200 let p = pattern.as_bytes();
17201 let c = candidate.as_bytes();
17202 let mut pi = 0usize;
17203 let mut ci = 0usize;
17204 let mut star: Option<usize> = None;
17205 let mut star_match = 0usize;
17206
17207 while ci < c.len() {
17208 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17209 pi += 1;
17210 ci += 1;
17211 } else if pi < p.len() && p[pi] == b'*' {
17212 while pi < p.len() && p[pi] == b'*' {
17213 pi += 1;
17214 }
17215 star = Some(pi);
17216 star_match = ci;
17217 } else if let Some(star_pi) = star {
17218 star_match += 1;
17219 ci = star_match;
17220 pi = star_pi;
17221 } else {
17222 return false;
17223 }
17224 }
17225
17226 while pi < p.len() && p[pi] == b'*' {
17227 pi += 1;
17228 }
17229
17230 pi == p.len()
17231}
17232
17233fn escape_html(value: &str) -> String {
17234 value
17235 .replace('&', "&")
17236 .replace('<', "<")
17237 .replace('>', ">")
17238 .replace('"', """)
17239 .replace('\'', "'")
17240}
17241
17242#[derive(Clone)]
17243struct SubmoduleRow {
17244 name: String,
17245 relative_path: String,
17246 files_analyzed: u64,
17247 code_lines: u64,
17248 comment_lines: u64,
17249 blank_lines: u64,
17250 total_physical_lines: u64,
17251 html_url: Option<String>,
17252}
17253
17254#[derive(Template)]
17255#[template(
17256 source = r##"
17257<!doctype html>
17258<html lang="en">
17259<head>
17260 <meta charset="utf-8">
17261 <title>OxideSLOC | tmp-sloc</title>
17262 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17263 <style nonce="{{ csp_nonce }}">
17264 :root {
17265 --bg: #efe9e2;
17266 --surface: #fcfaf7;
17267 --surface-2: #f7f0e8;
17268 --surface-3: #efe3d5;
17269 --line: #dfcfbf;
17270 --line-strong: #cfb29c;
17271 --text: #2f241c;
17272 --muted: #6f6257;
17273 --muted-2: #917f71;
17274 --nav: #b85d33;
17275 --nav-2: #7a371b;
17276 --accent: #2563eb;
17277 --accent-2: #1d4ed8;
17278 --oxide: #b85d33;
17279 --oxide-2: #8f4220;
17280 --success-bg: #eaf9ee;
17281 --success-text: #1c8746;
17282 --warn-bg: #fff2d8;
17283 --warn-text: #926000;
17284 --danger-bg: #fdeaea;
17285 --danger-text: #b33b3b;
17286 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17287 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17288 --radius: 14px;
17289 }
17290
17291 body.dark-theme {
17292 --bg: #1b1511;
17293 --surface: #261c17;
17294 --surface-2: #2d221d;
17295 --surface-3: #372922;
17296 --line: #524238;
17297 --line-strong: #6c5649;
17298 --text: #f5ece6;
17299 --muted: #c7b7aa;
17300 --muted-2: #aa9485;
17301 --nav: #b85d33;
17302 --nav-2: #7a371b;
17303 --accent: #6f9bff;
17304 --accent-2: #4a78ee;
17305 --oxide: #d37a4c;
17306 --oxide-2: #b35428;
17307 --success-bg: #163927;
17308 --success-text: #8fe2a8;
17309 --warn-bg: #3c2d11;
17310 --warn-text: #f3cb75;
17311 --danger-bg: #3d1f1f;
17312 --danger-text: #ff9f9f;
17313 --shadow: 0 14px 28px rgba(0,0,0,0.28);
17314 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17315 }
17316
17317 * { box-sizing: border-box; }
17318 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); }
17319 html { overflow-y: scroll; }
17320 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17321 .top-nav, .page, .loading { position: relative; z-index: 2; }
17322 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17323 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17324 .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); }
17325 .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; }
17326 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17327 .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)); }
17328 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17329 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17330 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17331 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17332 .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; }
17333 .nav-project-pill.visible { display:inline-flex; }
17334 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17335 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17336 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17337 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17338 @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; } }
17339 .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; }
17340 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17341 .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; }
17342 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17343 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17344 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17345 .theme-toggle .icon-sun { display:none; }
17346 body.dark-theme .theme-toggle .icon-sun { display:block; }
17347 body.dark-theme .theme-toggle .icon-moon { display:none; }
17348 .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;}
17349 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17350 .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);}
17351 .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;}
17352 .settings-close:hover{color:var(--text);background:var(--surface-2);}
17353 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17354 .settings-modal-body{padding:14px 16px 16px;}
17355 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17356 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17357 .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;}
17358 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17359 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17360 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17361 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17362 .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;}
17363 .tz-select:focus{border-color:var(--oxide);}
17364 .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; }
17365 .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;}
17366 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17367 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17368 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17369 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17370 .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; }
17371 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17372 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17373 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17374 .wb-stats-header { padding: 10px 24px 0; }
17375 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17376 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17377 .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; }
17378 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17379 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17380 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17381 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17382 .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; }
17383 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17384 .ws-stat-analyzers { position: relative; }
17385 .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; }
17386 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17387 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17388 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17389 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17390 .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; }
17391 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17392 .ws-divider { display: none; }
17393 .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%; }
17394 .ws-path-link:hover { color:var(--oxide); }
17395 body.dark-theme .ws-path-link { color:var(--oxide); }
17396 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17397 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17398 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17399 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17400 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17401 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17402 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17403 .ws-mini-box-lg { flex:2 1 0; }
17404 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17405 .ws-mini-box-br { flex:1.5 1 0; }
17406 .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; }
17407 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17408 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17409 #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; }
17410 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17411 .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; }
17412 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17413 .git-source-banner strong { font-weight:800; color:var(--text); }
17414 .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; }
17415 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17416 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17417 .git-source-banner a:hover { text-decoration:underline; }
17418 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17419 .path-scope-sep { background:var(--line); margin:4px 14px; }
17420 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17421 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17422 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17423 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17424 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17425 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17426 .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; }
17427 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17428 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17429 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17430 .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; }
17431 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17432 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17433 [data-wb-tip] { cursor:help; }
17434 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17435 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17436 .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; }
17437 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17438 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17439 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17440 .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; }
17441 .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); }
17442 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17443 .side-info-card { padding: 18px; }
17444 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17445 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17446 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17447 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17448 .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); }
17449 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17450 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17451 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17452 .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; }
17453 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17454 .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; }
17455 .side-stack::-webkit-scrollbar { display: none; }
17456 .step-nav { padding: 20px 16px; }
17457 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17458 .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; }
17459 .step-button:hover { background: var(--surface-2); }
17460 .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); }
17461 .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; }
17462 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17463 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17464 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17465 .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); }
17466 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17467 .step-nav-sum-row:last-child { border-bottom:none; }
17468 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17469 .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; }
17470 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17471 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17472 .quick-scan-section { padding: 10px 4px 14px; }
17473 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17474 .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; }
17475 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17476 .quick-scan-btn:active { transform:translateY(0); }
17477 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17478 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17479 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17480 @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);} }
17481 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17482 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17483 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17484 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17485 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17486 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17487 .step-button.done .step-check { opacity:1; }
17488 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17489 .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; }
17490 .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; }
17491 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17492 .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; }
17493 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17494 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17495 .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; }
17496 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17497 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17498 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17499 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17500 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17501 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17502 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17503 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17504 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17505 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17506 .card-body { padding: 22px; }
17507 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17508 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17509 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17510 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17511 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17512 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17513 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17514 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17515 .field { min-width:0; }
17516 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17517 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; }
17518 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); }
17519 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17520 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); }
17521 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17522 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17523 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17524 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17525 .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; }
17526 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17527 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17528 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17529 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17530 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17531 .input-group.compact { grid-template-columns: 1fr auto auto; }
17532 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17533 .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)); }
17534 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17535 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17536 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17537 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17538 .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; }
17539 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17540 .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; }
17541 .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); }
17542 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17543 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17544 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17545 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17546 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17547 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17548 @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; } }
17549 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17550 button.secondary { background: var(--surface); }
17551 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17552 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17553 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17554 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17555 .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); }
17556 .section + .wizard-actions { border-top: none; padding-top: 0; }
17557 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17558 .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; }
17559 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17560 .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; }
17561 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17562 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17563 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17564 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17565 .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); }
17566 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17567 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17568 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17569 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17570 .field-help-grid.coupled-help { margin-top: 12px; }
17571 .field-help-grid.preset-grid { align-items: start; }
17572 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17573 .preset-inline-row .field { margin: 0; }
17574 .preset-inline-row .explainer-card { margin: 0; }
17575 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17576 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17577 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17578 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17579 .preset-kv-row > :last-child { flex:1; min-width:0; }
17580 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17581 .output-field-row .field { margin: 0; }
17582 .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; }
17583 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17584 .step3-subtitle { margin-bottom: 10px; max-width: none; }
17585 .counting-intro { margin-bottom: 8px; max-width: none; }
17586 .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; }
17587 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17588 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17589 .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; }
17590 .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; }
17591 .section-spacer-top { margin-top: 28px; }
17592 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17593 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17594 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17595 .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); }
17596 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17597 .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; }
17598 .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; }
17599 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17600 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17601 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17602 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17603 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17604 .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; }
17605 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17606 .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); }
17607 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; }
17608 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; }
17609 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17610 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17611 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17612 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17613 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17614 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17615 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17616 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17617 .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); }
17618 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17619 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17620 .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; }
17621 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17622 .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; }
17623 .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; }
17624 .always-tracked-tip-body { flex:1; min-width:0; }
17625 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17626 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17627 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17628 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17629 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17630 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17631 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17632 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17633 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17634 .advanced-rule-description strong { color: var(--text); }
17635 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17636 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17637 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17638 .review-link:hover { text-decoration: underline; }
17639 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17640 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17641 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17642 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17643 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17644 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17645 .review-card ul { padding-left: 18px; margin: 0; }
17646 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17647 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17648 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17649 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17650 .review-card { min-height: 0; }
17651 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17652 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17653 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17654 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17655 .lang-overflow-chip { position:relative; cursor:default; }
17656 .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; }
17657 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17658 .git-inline-row { align-items:start; }
17659 .mixed-line-card { display:flex; flex-direction:column; }
17660 .preset-inline-row .toggle-card { justify-content: center; }
17661 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17662 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17663 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17664 .explorer-title { font-size: 18px; font-weight: 850; }
17665 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17666 .explorer-subtitle.wide { max-width: none; }
17667 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17668 .better-spacing { align-items:flex-start; justify-content:flex-end; }
17669 .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; }
17670 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17671 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17672 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17673 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17674 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17675 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17676 .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; }
17677 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17678 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17679 .scope-stat-button.supported { background: var(--success-bg); }
17680 .scope-stat-button.skipped { background: var(--warn-bg); }
17681 .scope-stat-button.unsupported { background: var(--danger-bg); }
17682 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17683 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17684 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17685 [data-tooltip] { position: relative; }
17686 [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); }
17687 [data-tooltip]:hover::after { display: block; }
17688 .scope-stat-button[data-tooltip] { cursor: pointer; }
17689 .badge[data-tooltip] { cursor: help; }
17690 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17691 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17692 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17693 .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; }
17694 .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; }
17695 code { display:inline-block; margin-top:0; padding:2px 7px; }
17696 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17697 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17698 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17699 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17700 .language-pill.muted-pill { color: var(--muted); }
17701 button.language-pill { appearance:none; cursor:pointer; }
17702 .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); }
17703 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17704 .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; }
17705 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17706 .file-explorer-search-row { margin-left: auto; }
17707 .explorer-filter-select { min-width: 170px; width: 170px; }
17708 .explorer-search { min-width: 300px; width: 300px; }
17709 .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); }
17710 .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; }
17711 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17712 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17713 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17714 .file-explorer-tree { max-height: 640px; overflow:auto; }
17715 .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); }
17716 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17717 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17718 .tree-row.hidden-by-filter { display:none !important; }
17719 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17720 .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; }
17721 .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; }
17722 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17723 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17724 .tree-node { display:inline-flex; align-items:center; min-width:0; }
17725 .tree-node-dir { color: var(--text); font-weight: 800; }
17726 .tree-node-supported { color: var(--success-text); }
17727 .tree-node-skipped { color: var(--warn-text); }
17728 .tree-node-unsupported { color: var(--danger-text); }
17729 .tree-node-more { color: var(--muted-2); font-style: italic; }
17730 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17731 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17732 .tree-status-cell { display:flex; justify-content:flex-start; }
17733 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17734 .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; }
17735 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17736 .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; }
17737 @keyframes prevSpin { to { transform:rotate(360deg); } }
17738 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17739 .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; }
17740 .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; }
17741 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17742 .preview-gate-info svg { width:16px; height:16px; }
17743 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17744 @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); } }
17745 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17746 .preview-loading-text { flex:1; min-width:0; }
17747 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17748 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17749 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17750 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17751 .cov-scan-idle { display:none; }
17752 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17753 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17754 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17755 .cov-scan-title { font-weight:600; font-size:12.5px; }
17756 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17757 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17758 .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; }
17759 .cov-scan-use:hover { opacity:.75; }
17760 .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; }
17761 .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; }
17762 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17763 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17764 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17765 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17766 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17767 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17768 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17769 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17770 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17771 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17772 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17773 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17774 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17775 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17776 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17777 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17778 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17779 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17780 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17781 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17782 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17783 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17784 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17785 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17786 .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); }
17787 .loading.active { display:flex; }
17788 /* Lock page scroll while the analysis modal is open so the removed scrollbar
17789 gutter doesn't pull the centered card slightly left of true center. */
17790 body.modal-open { overflow: hidden; }
17791 .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; }
17792 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17793 .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; }
17794 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17795 .loading-card > * { position:relative; z-index:1; }
17796 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17797 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%); }
17798 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17799 .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; }
17800 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17801 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17802 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17803 .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; }
17804 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17805 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17806 .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; }
17807 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17808 .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; }
17809 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17810 .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; }
17811 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17812 .lc-step.done { color:var(--muted);opacity:0.55; }
17813 .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; }
17814 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17815 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17816 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17817 .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; }
17818 .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; }
17819 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17820 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17821 .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; }
17822 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17823 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17824 .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; }
17825 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17826 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17827 .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; }
17828 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17829 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17830 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17831 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17832 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17833 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17834 .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; }
17835 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17836 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17837 .hidden { display:none !important; }
17838 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17839 .site-footer a{color:var(--muted);}
17840 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17841 @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; } }
17842 .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;}
17843 @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));}}
17844 .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;}
17845 .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; }
17846 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17847 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17848 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17849 .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; }
17850 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17851 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17852 .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; }
17853 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17854 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17855 .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; }
17856 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17857 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17858 .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; }
17859 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17860 .info-icon-btn:hover { color:var(--text); }
17861 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); }
17862 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17863 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17864 .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;}
17865 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17866 .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;}
17867 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17868 #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);}
17869 #offline-file-banner.show{display:flex;}
17870 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17871 #offline-file-banner .ofb-text{flex:1;}
17872 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17873 #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;}
17874 #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;}
17875 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17876 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17877 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17878 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17879 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17880 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17881 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17882 </style>
17883</head>
17884<body id="page-top">
17885 <div id="offline-file-banner" role="alert">
17886 <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>
17887 <span class="ofb-text">
17888 Charts, images, and navigation require the oxide-sloc server.
17889 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17890 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17891 The metric tables below are fully readable without the server.
17892 </span>
17893 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17894 </div>
17895 <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>
17896 <div class="background-watermarks" aria-hidden="true">
17897 <img src="/images/logo/logo-text.png" alt="" />
17898 <img src="/images/logo/logo-text.png" alt="" />
17899 <img src="/images/logo/logo-text.png" alt="" />
17900 <img src="/images/logo/logo-text.png" alt="" />
17901 <img src="/images/logo/logo-text.png" alt="" />
17902 <img src="/images/logo/logo-text.png" alt="" />
17903 <img src="/images/logo/logo-text.png" alt="" />
17904 <img src="/images/logo/logo-text.png" alt="" />
17905 <img src="/images/logo/logo-text.png" alt="" />
17906 <img src="/images/logo/logo-text.png" alt="" />
17907 <img src="/images/logo/logo-text.png" alt="" />
17908 <img src="/images/logo/logo-text.png" alt="" />
17909 <img src="/images/logo/logo-text.png" alt="" />
17910 <img src="/images/logo/logo-text.png" alt="" />
17911 </div>
17912 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17913 <div class="top-nav">
17914 <div class="top-nav-inner">
17915 <a class="brand" href="/">
17916 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17917 <div class="brand-copy">
17918 <div class="brand-title">OxideSLOC</div>
17919 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17920 </div>
17921 </a>
17922 <div class="nav-project-slot">
17923 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17924 <span class="nav-project-label">Project</span>
17925 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17926 </div>
17927 </div>
17928 <div class="nav-status">
17929 <a class="nav-pill" href="/">Home</a>
17930 <div class="nav-dropdown">
17931 <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>
17932 <div class="nav-dropdown-menu">
17933 <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>
17934 </div>
17935 </div>
17936 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17937 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17938 <div class="nav-dropdown">
17939 <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>
17940 <div class="nav-dropdown-menu">
17941 <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>
17942 </div>
17943 </div>
17944 <div class="server-status-wrap" id="server-status-wrap">
17945 <div class="nav-pill server-online-pill" id="server-status-pill">
17946 <span class="status-dot" id="status-dot"></span>
17947 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
17948 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17949 </div>
17950 <div class="server-status-tip">
17951 {% if server_mode %}
17952 OxideSLOC is running in server mode — accessible on your LAN.
17953 {% else %}
17954 OxideSLOC is running locally — only accessible from this machine.
17955 {% endif %}
17956 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17957 </div>
17958 </div>
17959 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17960 <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>
17961 </button>
17962 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17963 <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>
17964 <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>
17965 </button>
17966 </div>
17967 </div>
17968 </div>
17969
17970 <div class="loading" id="loading">
17971 <div class="loading-card" id="loading-card">
17972 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
17973 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17974 <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>
17975 <div class="lc-steps" id="lc-steps">
17976 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
17977 <div class="lc-step-arrow">›</div>
17978 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
17979 <div class="lc-step-arrow">›</div>
17980 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
17981 <div class="lc-step-arrow">›</div>
17982 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
17983 </div>
17984 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
17985 <div class="lc-metrics" id="lc-metrics">
17986 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
17987 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
17988 <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>
17989 <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>
17990 </div>
17991 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
17992 <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>
17993 <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>
17994 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
17995 <div class="lc-actions hidden" id="lc-actions">
17996 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
17997 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
17998 </div>
17999 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18000 <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>
18001 Cancel scan
18002 </button>
18003 </div>
18004 </div>
18005
18006 <div class="page">
18007 <div class="workbench-strip">
18008 <div class="workbench-box wb-stats">
18009 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18010 <span class="wb-stats-title">Analysis session</span>
18011 </div>
18012 <div class="ws-left">
18013 <div class="ws-stat ws-stat-analyzers">
18014 <span class="ws-label">Analyzers</span>
18015 <span class="ws-value">
18016 <span class="ws-badge">60 languages</span>
18017 </span>
18018 <div class="ws-lang-tooltip">
18019 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18020 <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>
18021 <div class="ws-lang-grid">
18022 <span class="ws-lang-item">Assembly</span>
18023 <span class="ws-lang-item">C</span>
18024 <span class="ws-lang-item">C++</span>
18025 <span class="ws-lang-item">C#</span>
18026 <span class="ws-lang-item">Clojure</span>
18027 <span class="ws-lang-item">CSS</span>
18028 <span class="ws-lang-item">Dart</span>
18029 <span class="ws-lang-item">Dockerfile</span>
18030 <span class="ws-lang-item">Elixir</span>
18031 <span class="ws-lang-item">Erlang</span>
18032 <span class="ws-lang-item">F#</span>
18033 <span class="ws-lang-item">Go</span>
18034 <span class="ws-lang-item">Groovy</span>
18035 <span class="ws-lang-item">Haskell</span>
18036 <span class="ws-lang-item">HTML</span>
18037 <span class="ws-lang-item">Java</span>
18038 <span class="ws-lang-item">JavaScript</span>
18039 <span class="ws-lang-item">Julia</span>
18040 <span class="ws-lang-item">Kotlin</span>
18041 <span class="ws-lang-item">Lua</span>
18042 <span class="ws-lang-item">Makefile</span>
18043 <span class="ws-lang-item">Nim</span>
18044 <span class="ws-lang-item">Obj-C</span>
18045 <span class="ws-lang-item">OCaml</span>
18046 <span class="ws-lang-item">Perl</span>
18047 <span class="ws-lang-item">PHP</span>
18048 <span class="ws-lang-item">PowerShell</span>
18049 <span class="ws-lang-item">Python</span>
18050 <span class="ws-lang-item">R</span>
18051 <span class="ws-lang-item">Ruby</span>
18052 <span class="ws-lang-item">Rust</span>
18053 <span class="ws-lang-item">Scala</span>
18054 <span class="ws-lang-item">SCSS</span>
18055 <span class="ws-lang-item">Shell</span>
18056 <span class="ws-lang-item">SQL</span>
18057 <span class="ws-lang-item">Svelte</span>
18058 <span class="ws-lang-item">Swift</span>
18059 <span class="ws-lang-item">TypeScript</span>
18060 <span class="ws-lang-item">Vue</span>
18061 <span class="ws-lang-item">XML</span>
18062 <span class="ws-lang-item">Zig</span>
18063 <span class="ws-lang-item">Solidity</span>
18064 <span class="ws-lang-item">Protobuf</span>
18065 <span class="ws-lang-item">HCL</span>
18066 <span class="ws-lang-item">GraphQL</span>
18067 <span class="ws-lang-item">Ada</span>
18068 <span class="ws-lang-item">VHDL</span>
18069 <span class="ws-lang-item">Verilog</span>
18070 <span class="ws-lang-item">Tcl</span>
18071 <span class="ws-lang-item">Pascal</span>
18072 <span class="ws-lang-item">Visual Basic</span>
18073 <span class="ws-lang-item">Lisp</span>
18074 <span class="ws-lang-item">Fortran</span>
18075 <span class="ws-lang-item">Nix</span>
18076 <span class="ws-lang-item">Crystal</span>
18077 <span class="ws-lang-item">D</span>
18078 <span class="ws-lang-item">GLSL</span>
18079 <span class="ws-lang-item">CMake</span>
18080 <span class="ws-lang-item">Elm</span>
18081 <span class="ws-lang-item">Awk</span>
18082 </div>
18083 </div>
18084 </div>
18085 <div class="ws-divider"></div>
18086 <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>
18087 <div class="ws-divider"></div>
18088 <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts \u2014 JSON, HTML, and PDF reports \u2014 are written after each completed scan.">
18089 <span class="ws-label">Output</span>
18090 <span class="ws-value">
18091 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18092 <span id="ws-output-root">project/sloc</span>
18093 </button>
18094 </span>
18095 </div>
18096 </div>
18097 </div>
18098 <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.">
18099 <div class="ws-history-label">Scan history</div>
18100 <div class="ws-history-inner">
18101 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18102 <div class="ws-mini-label">Scans</div>
18103 <div class="ws-mini-value" id="ws-scan-count">—</div>
18104 </div>
18105 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18106 <div class="ws-mini-label">Last Scan</div>
18107 <div class="ws-mini-value" id="ws-last-scan">—</div>
18108 </div>
18109 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18110 <div class="ws-mini-label">Branch</div>
18111 <div class="ws-mini-value" id="ws-branch">—</div>
18112 </div>
18113 </div>
18114 </div>
18115 </div>
18116
18117 <div class="layout">
18118 <aside class="side-stack">
18119 <section class="step-nav">
18120 <h3>Guided scan setup</h3>
18121 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18122 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18123 Top of page
18124 </a>
18125 <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>
18126 <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>
18127 <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>
18128 <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>
18129
18130 <div class="step-steps-divider"></div>
18131
18132 <div class="step-nav-info" id="step-nav-info">
18133 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18134 <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>
18135 </div>
18136
18137 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18138 <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>
18139 <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>
18140 <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>
18141 </div>
18142
18143 <div class="quick-scan-divider"></div>
18144 <div class="quick-scan-section">
18145 <div class="quick-scan-label">No customization needed?</div>
18146 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18147 <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>
18148 Quick Scan
18149 </button>
18150 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18151 </div>
18152
18153 <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>
18154 <div class="sidebar-scroll-divider"></div>
18155 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18156 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18157 Skip to bottom
18158 </a>
18159 </section>
18160
18161 </aside>
18162
18163 <section class="card">
18164 <div class="card-header">
18165 <div class="card-title-row">
18166 <div>
18167 <h1 class="card-title">Guided scan configuration</h1>
18168 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18169 </div>
18170 <div class="wizard-progress" aria-label="Scan setup progress">
18171 <div class="wizard-progress-top">
18172 <span class="wizard-progress-label">Setup progress</span>
18173 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18174 </div>
18175 <div class="wizard-progress-track">
18176 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18177 </div>
18178 </div>
18179 </div>
18180 </div>
18181 <div class="card-body">
18182 <form method="post" action="/analyze" id="analyze-form">
18183 <div class="wizard-step active" data-step="1">
18184 <div class="section">
18185 <div class="section-kicker">Step 1</div>
18186 <h2>Select project and preview scope</h2>
18187 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18188 <div class="field">
18189 <label for="path">Project path</label>
18190 {% if !git_repo.is_empty() %}
18191 <div class="git-source-banner">
18192 <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>
18193 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18194 <a href="/git-browser">← Back to Git Browser</a>
18195 </div>
18196 {% endif %}
18197 <div class="path-scope-grid">
18198 {% if !git_repo.is_empty() %}
18199 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18200 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18201 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18202 {% else %}
18203 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18204 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18205 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18206 {% endif %}
18207 <div class="path-scope-sep"></div>
18208 <div class="scope-legend-row">
18209 <span class="scope-legend-label">Scope legend:</span>
18210 <span class="scope-legend-badges">
18211 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer \u2014 counted in SLOC totals.">supported</span>
18212 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18213 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set \u2014 listed but not counted.">unsupported</span>
18214 </span>
18215 </div>
18216 </div>
18217 {% if git_repo.is_empty() %}
18218 {% if server_mode %}
18219 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18220 ℹ️ Files are compressed and streamed — no fixed size limit.
18221 </div>
18222 {% endif %}
18223 <div class="path-info-row">
18224 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18225 <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>
18226 <span id="project-size-text">Project size: —</span>
18227 </button>
18228 </div>
18229 {% else %}
18230 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18231 {% endif %}
18232 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18233 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18234 </div>
18235
18236 <div class="scope-preview-divider" aria-hidden="true"></div>
18237
18238 <div id="preview-panel">
18239 <div class="preview-error">Loading preview...</div>
18240 </div>
18241 </div>
18242
18243 <div class="section" style="margin-top:14px;">
18244 <div class="preset-inline-row git-inline-row">
18245 <div class="toggle-card" style="margin:0;">
18246 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18247 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18248 <label class="checkbox">
18249 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18250 <div>
18251 <span>Detect and separate git submodules</span>
18252 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18253 </div>
18254 </label>
18255 </div>
18256 <div class="explainer-card prominent" style="margin:0;">
18257 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18258 <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>
18259 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18260 path = libs/core
18261 url = https://github.com/org/core.git
18262
18263[submodule "libs/ui"]
18264 path = libs/ui
18265 url = https://github.com/org/ui.git</div>
18266 </div>
18267 </div>
18268 </div>
18269
18270 <div class="section">
18271 <div class="field-grid">
18272 <div class="field">
18273 <div class="glob-label-row">
18274 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18275 <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>
18276 </div>
18277 <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>
18278 <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>
18279 </div>
18280 <div class="field">
18281 <div class="glob-label-row">
18282 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18283 </div>
18284 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
18285 <div id="quick-exclude-chips" class="quick-excl-row">
18286 <span class="quick-excl-label">Quick add:</span>
18287 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18288 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18289 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18290 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18291 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18292 <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>
18293 </div>
18294 <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>
18295 </div>
18296 </div>
18297 <div class="glob-guidance-grid">
18298 <div class="glob-guidance-card">
18299 <strong>How to read them</strong>
18300 <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>
18301 </div>
18302 <div class="glob-guidance-card">
18303 <strong>Common include examples</strong>
18304 <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>
18305 </div>
18306 <div class="glob-guidance-card">
18307 <strong>Common exclude examples</strong>
18308 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18309 </div>
18310 </div>
18311 </div>
18312
18313 <div class="section" style="margin-top:14px;">
18314 <div class="preset-inline-row git-inline-row">
18315 <div class="toggle-card" style="margin:0;">
18316 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18317 <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>
18318 <div class="field" style="margin:0;">
18319 <div class="input-group compact">
18320 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18321 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18322 </div>
18323 <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>
18324 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18325 </div>
18326 </div>
18327 <div class="explainer-card prominent" style="margin:0;">
18328 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18329 <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>
18330 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18331lcov --capture --directory . --output-file coverage/lcov.info
18332
18333# C / C++ — llvm-cov (LCOV)
18334llvm-profdata merge -sparse default.profraw -o default.profdata
18335llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18336
18337# C# — coverlet (Cobertura XML)
18338dotnet test --collect:"XPlat Code Coverage"
18339
18340# Python — pytest-cov (Cobertura XML)
18341pytest --cov --cov-report=xml
18342
18343# Python — coverage.py native JSON
18344coverage run -m pytest && coverage json # writes coverage.json
18345
18346# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18347./gradlew jacocoTestReport</div>
18348 </div>
18349 </div>
18350 </div>
18351
18352 <div class="wizard-actions">
18353 <div class="left"></div>
18354 <div class="right">
18355 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18356 <span class="preview-gate-spinner" aria-hidden="true"></span>
18357 <span class="preview-gate-text">Scanning project scope…</span>
18358 <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">
18359 <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>
18360 </button>
18361 </div>
18362 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18363 </div>
18364 </div>
18365 </div>
18366
18367 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18368 <div class="default-path-modal">
18369 <h3 id="default-path-title">
18370 <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>
18371 Proceed with the default sample test?
18372 </h3>
18373 <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18374 <p>You haven't selected your own project yet.</p>
18375 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18376 <div class="default-path-actions">
18377 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18378 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18379 </div>
18380 </div>
18381 </div>
18382
18383 <div class="wizard-step" data-step="2">
18384 <div class="section">
18385 <div class="section-kicker">Step 2</div>
18386 <h2>Choose counting behavior</h2>
18387 <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>
18388<div class="subsection-bar">Primary line classification</div>
18389 <div class="preset-kv-row">
18390 <div class="toggle-card mixed-line-card" style="margin:0;">
18391 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18392 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18393 <select id="mixed_line_policy" name="mixed_line_policy">
18394 <option value="code_only">Code only</option>
18395 <option value="code_and_comment">Code and comment</option>
18396 <option value="comment_only">Comment only</option>
18397 <option value="separate_mixed_category">Separate mixed category</option>
18398 </select>
18399 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18400 </div>
18401 <div class="explainer-card prominent" style="margin:0;">
18402 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18403 <div class="explainer-body" id="mixed-policy-description"></div>
18404 <div class="code-sample" id="mixed-policy-example"></div>
18405 </div>
18406 </div>
18407 </div>
18408
18409 <div class="subsection-bar">Additional scan rules</div>
18410 <div class="scan-rules-grid">
18411 <div class="preset-inline-row">
18412 <div class="toggle-card" style="margin:0;">
18413 <div class="field-help-title">Generated files</div>
18414 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18415 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18416 </div>
18417 <div class="explainer-card prominent" style="margin:0;">
18418 <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>
18419 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18420# Files matching codegen patterns are excluded:
18421# *.generated.cs *.pb.go *.g.dart</div>
18422 </div>
18423 </div>
18424 <div class="preset-inline-row">
18425 <div class="toggle-card" style="margin:0;">
18426 <div class="field-help-title">Minified files</div>
18427 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18428 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18429 </div>
18430 <div class="explainer-card prominent" style="margin:0;">
18431 <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>
18432 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18433# Heuristic: very long lines + low whitespace ratio
18434# jquery.min.js bundle.min.css → skipped</div>
18435 </div>
18436 </div>
18437 <div class="preset-inline-row">
18438 <div class="toggle-card" style="margin:0;">
18439 <div class="field-help-title">Vendor directories</div>
18440 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18441 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18442 </div>
18443 <div class="explainer-card prominent" style="margin:0;">
18444 <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>
18445 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18446# Directories named vendor/ node_modules/ third_party/
18447# → entire subtree is excluded from totals</div>
18448 </div>
18449 </div>
18450 <div class="preset-inline-row">
18451 <div class="toggle-card" style="margin:0;">
18452 <div class="field-help-title">Lockfiles and manifests</div>
18453 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18454 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18455 </div>
18456 <div class="explainer-card prominent" style="margin:0;">
18457 <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>
18458 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
18459# Files like package-lock.json Cargo.lock yarn.lock
18460# → skipped unless this is enabled</div>
18461 </div>
18462 </div>
18463 <div class="preset-inline-row">
18464 <div class="toggle-card" style="margin:0;">
18465 <div class="field-help-title">Binary handling</div>
18466 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18467 <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>
18468 </div>
18469 <div class="explainer-card prominent" style="margin:0;">
18470 <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>
18471 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
18472# Detected via long lines + low whitespace heuristic
18473# .png .exe .so → skipped silently</div>
18474 </div>
18475 </div>
18476 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18477 <div class="toggle-card" style="margin:0;">
18478 <div class="field-help-title">Python docstrings</div>
18479 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18480 <label class="checkbox">
18481 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18482 <span>Count as comment-style lines</span>
18483 </label>
18484 </div>
18485 <div class="explainer-card prominent" style="margin:0;">
18486 <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>
18487 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18488 </div>
18489 </div>
18490 </div>
18491 <div class="subsection-bar">IEEE 1045-1992 counting</div>
18492 <div class="scan-rules-grid">
18493 <div class="preset-inline-row">
18494 <div class="toggle-card" style="margin:0;">
18495 <div class="field-help-title">Continuation lines</div>
18496 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18497 <select name="continuation_line_policy" id="continuation_line_policy">
18498 <option value="each_physical_line" selected>Each physical line (default)</option>
18499 <option value="collapse_to_logical">Collapse to logical line</option>
18500 </select>
18501 </div>
18502 <div class="explainer-card prominent" style="margin:0;">
18503 <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>
18504 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18505 ((a) > (b) ? (a) : (b))
18506# each_physical_line → 2 SLOC
18507# collapse_to_logical → 1 SLOC</div>
18508 </div>
18509 </div>
18510 <div class="preset-inline-row">
18511 <div class="toggle-card" style="margin:0;">
18512 <div class="field-help-title">Block-comment blanks</div>
18513 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18514 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18515 <option value="count_as_comment" selected>Count as comment (default)</option>
18516 <option value="count_as_blank">Count as blank</option>
18517 </select>
18518 </div>
18519 <div class="explainer-card prominent" style="margin:0;">
18520 <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>
18521 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18522 * Summary line
18523 * ← blank inside block comment
18524 * Detail line
18525 */
18526# count_as_comment → blank counts toward comments
18527# count_as_blank → blank counts toward blanks</div>
18528 </div>
18529 </div>
18530 <div class="preset-inline-row">
18531 <div class="toggle-card" style="margin:0;">
18532 <div class="field-help-title">Compiler directives</div>
18533 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18534 <select name="count_compiler_directives" id="count_compiler_directives">
18535 <option value="enabled" selected>Include in code SLOC (default)</option>
18536 <option value="disabled">Exclude from code SLOC</option>
18537 </select>
18538 </div>
18539 <div class="explainer-card prominent" style="margin:0;">
18540 <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>
18541 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
18542#define BUF 256 ← compiler directive
18543int main() { … } ← code
18544# enabled → 3 code SLOC
18545# disabled → 1 code SLOC + 2 directive lines</div>
18546 </div>
18547 </div>
18548 </div>
18549
18550 <div class="subsection-bar">Code Style Analysis</div>
18551 <div class="scan-rules-grid">
18552 <div class="preset-inline-row">
18553 <div class="toggle-card" style="margin:0;">
18554 <div class="field-help-title">Style analysis</div>
18555 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18556 <select name="style_analysis_enabled" id="style_analysis_enabled">
18557 <option value="enabled" selected>Enabled (default)</option>
18558 <option value="disabled">Disabled — skip style scoring</option>
18559 </select>
18560 </div>
18561 <div class="explainer-card prominent" style="margin:0;">
18562 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> \u2014 every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> \u2014 style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
18563 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
18564# style_analysis_enabled = false (skip, faster scan)
18565# Disabling removes the Code Style section from the report.</div>
18566 </div>
18567 </div>
18568 <div class="preset-inline-row">
18569 <div class="toggle-card" style="margin:0;">
18570 <div class="field-help-title">Column-width threshold</div>
18571 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18572 <select name="style_col_threshold" id="style_col_threshold">
18573 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18574 <option value="100">100 columns (Uber Go, Google Java)</option>
18575 <option value="120">120 columns (Uber Go max, Kotlin)</option>
18576 </select>
18577 </div>
18578 <div class="explainer-card prominent" style="margin:0;">
18579 <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>
18580 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
18581# style_col_threshold = 100 (Uber Go, Google Java)
18582# style_col_threshold = 120 (Uber Go max, Kotlin)
18583# Files where <= 5% of lines exceed the limit
18584# are counted as "N-col compliant" in the report.</div>
18585 </div>
18586 </div>
18587 <div class="preset-inline-row">
18588 <div class="toggle-card" style="margin:0;">
18589 <div class="field-help-title">Score alert threshold</div>
18590 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18591 <select name="style_score_threshold" id="style_score_threshold">
18592 <option value="0" selected>Off — no threshold (default)</option>
18593 <option value="40">40% — flag poorly styled files</option>
18594 <option value="50">50% — flag below-average files</option>
18595 <option value="60">60% — flag below-good files</option>
18596 <option value="70">70% — flag below-strong files</option>
18597 </select>
18598 </div>
18599 <div class="explainer-card prominent" style="margin:0;">
18600 <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>
18601 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
18602# style_score_threshold = 50 (flag files < 50%)
18603# Low-scoring files get a red left-border in the
18604# per-file style breakdown table.</div>
18605 </div>
18606 </div>
18607 </div>
18608
18609 <div class="always-tracked-tip">
18610 <div class="always-tracked-tip-icon">ℹ</div>
18611 <div class="always-tracked-tip-body">
18612 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
18613 <h4>Comment and blank-line basics & Lines on the boundary</h4>
18614 <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>
18615 </div>
18616 </div>
18617
18618 <div class="subsection-bar">Advanced Metrics</div>
18619 <div class="scan-rules-grid">
18620 <div class="preset-inline-row">
18621 <div class="toggle-card" style="margin:0;">
18622 <div class="field-help-title">COCOMO mode</div>
18623 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18624 <select name="cocomo_mode" id="cocomo_mode">
18625 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18626 <option value="semi_detached">Semi-detached — mixed constraints</option>
18627 <option value="embedded">Embedded — tight hardware/OS constraints</option>
18628 </select>
18629 </div>
18630 <div class="explainer-card prominent" style="margin:0;">
18631 <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>
18632 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
18633# Semi-detached: Effort = 3.0 × KSLOC^1.12
18634# Embedded: Effort = 3.6 × KSLOC^1.20
18635# All modes: Schedule = 2.5 × Effort^d</div>
18636 </div>
18637 </div>
18638 <div class="preset-inline-row">
18639 <div class="toggle-card" style="margin:0;">
18640 <div class="field-help-title">Complexity alert</div>
18641 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18642 <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;" />
18643 </div>
18644 <div class="explainer-card prominent" style="margin:0;">
18645 <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>
18646 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18647# 50 = flag any file with > 50 branch points
18648# 100 = flag any file with > 100 branch points
18649# Files above the threshold are highlighted
18650# in the result page metric strip.</div>
18651 </div>
18652 </div>
18653 <div class="preset-inline-row">
18654 <div class="toggle-card" style="margin:0;">
18655 <div class="field-help-title">Git hotspots</div>
18656 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18657 <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;" />
18658 </div>
18659 <div class="explainer-card prominent" style="margin:0;">
18660 <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>
18661 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
18662# 30 = last month of activity
18663# 365 = last year
18664# 0 = disable the hotspots table
18665# Adds Commits + Last-changed columns to CSV.</div>
18666 </div>
18667 </div>
18668 <div class="preset-inline-row">
18669 <div class="toggle-card" style="margin:0;">
18670 <div class="field-help-title">Duplicate handling</div>
18671 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18672 <select name="exclude_duplicates" id="exclude_duplicates">
18673 <option value="disabled" selected>Detect and report only (default)</option>
18674 <option value="enabled">Detect and exclude from SLOC totals</option>
18675 </select>
18676 </div>
18677 <div class="explainer-card prominent" style="margin:0;">
18678 <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>
18679 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18680# detect only → all 3 counted in SLOC
18681# exclude dupes → 1 counted, 2 excluded
18682# Duplicate groups chip always shows the count.</div>
18683 </div>
18684 </div>
18685 <div class="always-tracked-tip" style="margin:8px 0 0;">
18686 <div class="always-tracked-tip-icon">ℹ</div>
18687 <div class="always-tracked-tip-body">
18688 <div class="field-help-title">Always computed — every scan produces these automatically</div>
18689 <div class="always-tracked-metrics-row">
18690 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18691 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
18692 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
18693 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
18694 </div>
18695 <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>
18696 </div>
18697 </div>
18698 </div>
18699
18700 <div class="wizard-actions">
18701 <div class="left">
18702 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18703 </div>
18704 <div class="right">
18705 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18706 </div>
18707 </div>
18708 </div>
18709
18710 <div class="wizard-step" data-step="3">
18711 <div class="section">
18712 <div class="section-kicker">Step 3</div>
18713 <h2>Output and report identity</h2>
18714 <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>
18715 <div class="preset-kv-row">
18716 <div class="toggle-card" style="margin:0;">
18717 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18718 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18719 <select id="scan_preset">
18720 <option value="balanced">Balanced local scan</option>
18721 <option value="code_focused">Code focused</option>
18722 <option value="comment_audit">Comment audit</option>
18723 <option value="deep_review">Deep review</option>
18724 </select>
18725 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18726 </div>
18727 <div class="explainer-card">
18728 <div class="field-help-title">Selected scan preset</div>
18729 <div class="explainer-body" id="scan-preset-description"></div>
18730 <div class="preset-summary-row" id="scan-preset-summary"></div>
18731 <div class="code-sample" id="scan-preset-example"></div>
18732 <div class="preset-note" id="scan-preset-note"></div>
18733 </div>
18734 </div>
18735 <hr class="step3-separator" />
18736 <div class="preset-kv-row">
18737 <div class="toggle-card" style="margin:0;">
18738 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18739 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18740 <select id="artifact_preset">
18741 <option value="review">Review bundle</option>
18742 <option value="full">Full bundle</option>
18743 <option value="html_only">HTML only</option>
18744 <option value="machine">Machine bundle</option>
18745 </select>
18746 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18747 </div>
18748 <div class="explainer-card">
18749 <div class="field-help-title">Selected artifact preset</div>
18750 <div class="explainer-body" id="artifact-preset-description"></div>
18751 <div class="preset-summary-row" id="artifact-preset-summary"></div>
18752 <div class="code-sample" id="artifact-preset-example"></div>
18753 </div>
18754 </div>
18755 </div>
18756
18757 <div class="section section-spacer-top">
18758 <div class="output-field-row">
18759 <div class="field">
18760 <label for="output_dir">Output directory</label>
18761 {% if server_mode %}
18762 <div class="input-group compact">
18763 <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);" />
18764 </div>
18765 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18766 {% else %}
18767 <div class="input-group compact">
18768 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18769 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18770 <button type="button" class="mini-button" id="use-default-output">Use default</button>
18771 </div>
18772 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18773 {% endif %}
18774 </div>
18775 <div class="output-field-aside">
18776 <strong>Where reports land</strong>
18777 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.
18778 </div>
18779 </div>
18780 </div>
18781
18782 <div class="section section-spacer-top">
18783 <div class="output-field-row">
18784 <div class="field">
18785 <label for="report_title">Report title</label>
18786 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18787 <div class="hint">Appears in HTML and PDF output headers.</div>
18788 </div>
18789 <div class="output-field-aside">
18790 <strong>Shown in exported artifacts</strong>
18791 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.
18792 </div>
18793 </div>
18794 </div>
18795
18796 <div class="section section-spacer-top">
18797 <div class="output-field-row">
18798 <div class="field">
18799 <label for="report_header_footer">Report header / footer</label>
18800 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18801 <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>
18802 </div>
18803 <div class="output-field-aside">
18804 <strong>Page-level identification</strong>
18805 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.
18806 </div>
18807 </div>
18808 </div>
18809
18810 <div class="wizard-actions">
18811 <div class="left">
18812 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18813 </div>
18814 <div class="right">
18815 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18816 </div>
18817 </div>
18818 </div>
18819
18820 <div class="wizard-step" data-step="4">
18821 <div class="section">
18822 <div class="section-kicker">Step 4</div>
18823 <h2>Review selections and run</h2>
18824 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18825 <div class="review-grid">
18826 <div class="review-card highlight">
18827 <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>
18828 <ul id="review-scan-summary"></ul>
18829 </div>
18830 <div class="review-card highlight">
18831 <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>
18832 <ul id="review-count-summary"></ul>
18833 </div>
18834 <div class="review-card">
18835 <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>
18836 <ul id="review-artifact-summary"></ul>
18837 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18838 </div>
18839 <div class="review-card">
18840 <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>
18841 <ul id="review-preview-summary"></ul>
18842 </div>
18843 </div>
18844 </div>
18845
18846 <div class="wizard-actions">
18847 <div class="left">
18848 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18849 </div>
18850 <div class="right">
18851 <button type="submit" id="submit-button" class="primary">Run analysis</button>
18852 </div>
18853 </div>
18854 </div>
18855 {% if server_mode %}
18856 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18857 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18858 {% endif %}
18859 </form>
18860 </div>
18861 </section>
18862 </div>
18863 </div>
18864
18865 <script nonce="{{ csp_nonce }}">
18866 (function () {
18867 function startScanPhase() {
18868 var phaseEl = document.getElementById("scan-phase");
18869 if (!phaseEl) return;
18870 var phases = [
18871 "Discovering files...",
18872 "Decoding file encodings...",
18873 "Detecting languages...",
18874 "Analyzing source lines...",
18875 "Applying counting policies...",
18876 "Aggregating results...",
18877 "Rendering report..."
18878 ];
18879 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18880 var i = 0;
18881 function next() {
18882 phaseEl.style.opacity = "0";
18883 setTimeout(function () {
18884 phaseEl.textContent = phases[i];
18885 phaseEl.style.opacity = "0.85";
18886 var delay = durations[i] || 1800;
18887 i++;
18888 if (i < phases.length) { setTimeout(next, delay); }
18889 }, 200);
18890 }
18891 next();
18892 }
18893
18894 var form = document.getElementById("analyze-form");
18895 var loading = document.getElementById("loading");
18896 var submitButton = document.getElementById("submit-button");
18897 var pathInput = document.getElementById("path");
18898 var GIT_MODE = !!(pathInput && pathInput.readOnly);
18899 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18900 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18901 var outputDirInput = document.getElementById("output_dir");
18902 var reportTitleInput = document.getElementById("report_title");
18903 var previewPanel = document.getElementById("preview-panel");
18904 var refreshButton = document.getElementById("refresh-preview");
18905 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18906 var useSamplePath = document.getElementById("use-sample-path");
18907 var useDefaultOutput = document.getElementById("use-default-output");
18908 var browsePath = document.getElementById("browse-path");
18909 var browseOutputDir = document.getElementById("browse-output-dir");
18910 var browseCoverage = document.getElementById("browse-coverage");
18911 var coverageInput = document.getElementById("coverage_file");
18912 var covScanStatus = document.getElementById("cov-scan-status");
18913 var coverageSuggestTimer = null;
18914 var covAutoFilled = false;
18915 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18916
18917 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18918 (function() {
18919 var ids = ["path", "output_dir"];
18920 ids.forEach(function(id) {
18921 var el = document.getElementById(id);
18922 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18923 });
18924 }());
18925 function fmtBytes(b) {
18926 b = Number(b) || 0;
18927 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18928 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18929 if (b >= 1024) return Math.round(b / 1024) + ' KB';
18930 return b + ' B';
18931 }
18932 var themeToggle = document.getElementById("theme-toggle");
18933
18934 function showBannerToast(msg, isError, opts) {
18935 opts = opts || {};
18936 var t = document.createElement('div');
18937 t.className = isError ? 'toast-error' : 'toast-success';
18938 var topPos = opts.top ? '80px' : null;
18939 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
18940 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
18941 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
18942 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
18943 if (opts.icon) {
18944 var inner = document.createElement('span');
18945 inner.innerHTML = opts.icon + ' ';
18946 t.appendChild(inner);
18947 }
18948 t.appendChild(document.createTextNode(msg));
18949 document.body.appendChild(t);
18950 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
18951 }
18952 var mixedLinePolicy = document.getElementById("mixed_line_policy");
18953 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
18954 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
18955 var scanPreset = document.getElementById("scan_preset");
18956 var artifactPreset = document.getElementById("artifact_preset");
18957 var includeGlobsInput = document.getElementById("include_globs");
18958 var excludeGlobsInput = document.getElementById("exclude_globs");
18959
18960 // Include globs scope badge — updates reactively as the user types.
18961 (function() {
18962 var badge = document.getElementById("include-scope-badge");
18963 if (!badge || !includeGlobsInput) return;
18964 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> ';
18965 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> ';
18966 function update() {
18967 var val = includeGlobsInput.value.trim();
18968 if (!val) {
18969 badge.className = "include-scope-badge scope-all";
18970 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
18971 } else {
18972 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
18973 badge.className = "include-scope-badge scope-narrow";
18974 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
18975 }
18976 }
18977 includeGlobsInput.addEventListener("input", update);
18978 update();
18979 }());
18980
18981 // Quick-exclude chips — append pattern to exclude_globs textarea.
18982 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
18983 chip.addEventListener("click", function() {
18984 var pattern = chip.getAttribute("data-pattern") || "";
18985 if (!pattern || !excludeGlobsInput) return;
18986 var current = excludeGlobsInput.value.trim();
18987 // For the "skip all" chip, replace any existing dep patterns cleanly.
18988 var patterns = pattern.split("\n");
18989 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
18990 var added = false;
18991 patterns.forEach(function(p) {
18992 p = p.trim();
18993 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
18994 });
18995 if (added) {
18996 excludeGlobsInput.value = lines.join("\n");
18997 excludeGlobsInput.dispatchEvent(new Event("input"));
18998 }
18999 chip.classList.add("active");
19000 });
19001 });
19002
19003 var liveReportTitle = document.getElementById("live-report-title");
19004 var navProjectPill = document.getElementById("nav-project-pill");
19005 var navProjectTitle = document.getElementById("nav-project-title");
19006 var reportTitlePreview = null;
19007 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19008 var wizardProgressValue = document.getElementById("wizard-progress-value");
19009 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19010 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19011 var reportTitleTouched = false;
19012 var currentStep = 1;
19013 var previewTimer = null;
19014 var _previewGen = 0;
19015 // True while the scope preview (local) / project upload (server mode) is in
19016 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19017 // user can't advance past a project whose scope/upload isn't ready yet.
19018 var previewLoading = false;
19019 function setPreviewLoading(loading) {
19020 previewLoading = !!loading;
19021 var nextBtn = document.getElementById("step1-next");
19022 var gate = document.getElementById("preview-gate-status");
19023 if (nextBtn) {
19024 nextBtn.classList.toggle("is-blocked", previewLoading);
19025 nextBtn.setAttribute("aria-disabled", previewLoading ? "true" : "false");
19026 }
19027 if (gate) {
19028 var txt = gate.querySelector(".preview-gate-text");
19029 if (txt) txt.textContent = SERVER_MODE
19030 ? "Uploading & scanning project…"
19031 : "Scanning project scope…";
19032 gate.style.display = previewLoading ? "flex" : "none";
19033 }
19034 }
19035 // Info button on the gate: scroll up to the live scope preview so the user
19036 // can see exactly what is being scanned (elapsed time + rotating status).
19037 var previewGateInfo = document.getElementById("preview-gate-info");
19038 if (previewGateInfo) {
19039 previewGateInfo.addEventListener("click", function () {
19040 var target = document.getElementById("preview-panel");
19041 if (!target) return;
19042 target.scrollIntoView({ behavior: "smooth", block: "center" });
19043 target.classList.add("preview-panel-flash");
19044 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19045 });
19046 }
19047 var quickScanBtn = document.getElementById("quick-scan-btn");
19048
19049 function dismissAnalysisModal() {
19050 if (loading) loading.classList.remove("active");
19051 document.body.classList.remove("modal-open");
19052 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19053 var el = document.getElementById(id);
19054 if (el) el.classList.add("hidden");
19055 });
19056 var cancelBtn = document.getElementById("lc-cancel-btn");
19057 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19058 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19059 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19060 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19061 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");}
19062 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19063 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19064 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19065 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19066 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19067 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19068 }
19069
19070 var lcDismissBtn = document.getElementById("lc-dismiss");
19071 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19072
19073 // When the browser restores this page from bfcache (Back button after navigating to results),
19074 // the loading overlay would still be showing its active state. Dismiss it immediately.
19075 window.addEventListener("pageshow", function(e) {
19076 if (e.persisted) { dismissAnalysisModal(); }
19077 });
19078
19079 function startAsyncAnalysis(formData) {
19080 var gitRepo = (formData.get("git_repo") || "").toString();
19081 var gitRef = (formData.get("git_ref") || "").toString();
19082 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19083 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19084
19085 var pathEl = document.getElementById("lc-path-text");
19086 if (pathEl) pathEl.textContent = displayPath;
19087
19088 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19089 var el = document.getElementById(id);
19090 if (el) el.classList.add("hidden");
19091 });
19092 var cancelBtn = document.getElementById("lc-cancel-btn");
19093 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19094 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19095 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19096 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19097 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19098 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19099 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19100 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");}
19101 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19102
19103 if (loading) loading.classList.add("active");
19104 document.body.classList.add("modal-open");
19105
19106 var startTime = Date.now();
19107 var elapsedTimer = setInterval(function() {
19108 var s = Math.floor((Date.now() - startTime) / 1000);
19109 var el = document.getElementById("lc-elapsed");
19110 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19111 }, 1000);
19112
19113 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19114
19115 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();}
19116
19117 var PHASE_DESC = {
19118 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19119 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19120 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19121 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19122 'Done': 'Analysis complete \u2014 loading your results\u2026',
19123 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19124 };
19125 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19126 function lcSetPhase(txt) {
19127 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19128 var desc = document.getElementById("lc-stage-desc");
19129 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19130 var step = PHASE_STEP[txt] || 1;
19131 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");}
19132 }
19133
19134 function lcShowCancelled() {
19135 clearInterval(elapsedTimer);
19136 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19137 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19138 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19139 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19140 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19141 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19142 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19143 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19144 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19145 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19146 }
19147
19148 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19149 if (lcCancelBtn) {
19150 lcCancelBtn.onclick = function() {
19151 if (!activeWaitId) { dismissAnalysisModal(); return; }
19152 lcCancelBtn.disabled = true;
19153 lcCancelBtn.textContent = "Cancelling\u2026";
19154 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19155 .then(function() { lcShowCancelled(); })
19156 .catch(function() { lcShowCancelled(); });
19157 };
19158 }
19159
19160 function lcShowError(msg) {
19161 clearInterval(elapsedTimer);
19162 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19163 lcSetPhase("Failed");
19164 var msgEl = document.getElementById("lc-err-msg");
19165 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19166 var errEl = document.getElementById("lc-err");
19167 var actEl = document.getElementById("lc-actions");
19168 if (errEl) errEl.classList.remove("hidden");
19169 if (actEl) actEl.classList.remove("hidden");
19170 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19171 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19172 }
19173
19174 function lcPoll(waitId) {
19175 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19176 .then(function(r) {
19177 if (!r.ok) throw new Error("HTTP " + r.status);
19178 return r.json();
19179 })
19180 .then(function(data) {
19181 pollRetries = 0;
19182 if (data.state === "complete") {
19183 clearInterval(elapsedTimer);
19184 lcSetPhase("Done");
19185 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19186 } else if (data.state === "failed") {
19187 lcShowError(data.message);
19188 } else if (data.state === "cancelled") {
19189 lcShowCancelled();
19190 } else {
19191 var s = Math.floor((Date.now() - startTime) / 1000);
19192 if (s > 90 && !warnShown) {
19193 warnShown = true;
19194 var w = document.getElementById("lc-warn");
19195 if (w) w.classList.remove("hidden");
19196 }
19197 lcSetPhase(data.phase || "Running");
19198 var fd = data.files_done || 0, ft = data.files_total || 0;
19199 if (ft > 0) {
19200 var card = document.getElementById("lc-files-card");
19201 if (card) card.classList.remove("hidden");
19202 var el = document.getElementById("lc-files");
19203 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19204 var now = Date.now();
19205 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19206 if (fdelta > 0 && tdelta > 0.4) {
19207 var fps = Math.round(fdelta / tdelta);
19208 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19209 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19210 }
19211 lastFd = fd; lastFdTime = now;
19212 }
19213 setTimeout(function() { lcPoll(waitId); }, 1500);
19214 }
19215 })
19216 .catch(function() {
19217 pollRetries++;
19218 if (pollRetries >= 5) {
19219 lcShowError("Lost connection to server. Reload to check status.");
19220 } else {
19221 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19222 }
19223 });
19224 }
19225
19226 var params = new URLSearchParams(formData);
19227 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19228 .then(function(r) {
19229 var waitId = r.headers.get("x-wait-id");
19230 if (!waitId) { window.location.href = "/scan"; return; }
19231 activeWaitId = waitId;
19232 setTimeout(function() { lcPoll(waitId); }, 1500);
19233 })
19234 .catch(function(err) {
19235 lcShowError("Could not reach server: " + (err.message || err));
19236 });
19237 }
19238
19239 if (quickScanBtn) {
19240 quickScanBtn.addEventListener("click", function () {
19241 var pathVal = pathInput ? pathInput.value.trim() : "";
19242 if (!pathVal) {
19243 alert("Please enter or browse to a project path first.");
19244 return;
19245 }
19246 quickScanBtn.disabled = true;
19247 quickScanBtn.textContent = "Scanning...";
19248 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19249 startAsyncAnalysis(new FormData(form));
19250 });
19251 }
19252
19253 var mixedPolicyInfo = {
19254 code_only: {
19255 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.",
19256 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'
19257 },
19258 code_and_comment: {
19259 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.",
19260 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'
19261 },
19262 comment_only: {
19263 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.",
19264 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'
19265 },
19266 separate_mixed_category: {
19267 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.",
19268 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'
19269 }
19270 };
19271
19272 var scanPresetInfo = {
19273 balanced: {
19274 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.",
19275 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19276 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19277 note: "Best when you want a stable local overview before making deeper adjustments.",
19278 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19279 },
19280 code_focused: {
19281 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19282 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19283 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19284 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19285 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19286 },
19287 comment_audit: {
19288 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19289 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19290 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19291 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19292 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19293 },
19294 deep_review: {
19295 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19296 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19297 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19298 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19299 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19300 }
19301 };
19302
19303 var artifactPresetInfo = {
19304 review: {
19305 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19306 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19307 example: "Ideal for a quick local review before sharing results."
19308 },
19309 full: {
19310 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19311 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19312 example: "Use when producing a deliverable or storing a snapshot for future comparison."
19313 },
19314 html_only: {
19315 description: "Standalone HTML report only. No PDF generation, no data files.",
19316 chips: ["HTML only"],
19317 example: "Fastest option when you only need to open the report in a browser."
19318 },
19319 machine: {
19320 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19321 chips: ["JSON", "CSV", "no HTML", "no PDF"],
19322 example: "Use in CI to capture metrics without generating visual reports."
19323 }
19324 };
19325
19326 function applyArtifactPreset() {
19327 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19328 if (!info) return;
19329 var descEl = document.getElementById("artifact-preset-description");
19330 var exampleEl = document.getElementById("artifact-preset-example");
19331 if (descEl) descEl.textContent = info.description;
19332 if (exampleEl) exampleEl.textContent = info.example;
19333 renderPresetChips("artifact-preset-summary", info.chips);
19334 }
19335
19336 function applyTheme(theme) {
19337 if (theme === "dark") document.body.classList.add("dark-theme");
19338 else document.body.classList.remove("dark-theme");
19339 }
19340
19341 function loadSavedTheme() {
19342 var saved = null;
19343 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19344 applyTheme(saved === "dark" ? "dark" : "light");
19345 }
19346
19347 function updateScrollProgress() {
19348 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19349 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19350 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19351 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19352 var step = Math.min(Math.max(currentStep, 1), 4);
19353 var base = stepBase[step];
19354 var end = stepEnd[step];
19355
19356 var scrollFrac = 0;
19357 var activePanel = document.querySelector(".wizard-step.active");
19358 if (activePanel) {
19359 var scrollTop = window.scrollY || window.pageYOffset || 0;
19360 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19361 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19362 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19363 var scrolled = scrollTop + viewH - panelTop;
19364 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19365 }
19366
19367 var percent = Math.round(base + (end - base) * scrollFrac);
19368 percent = Math.min(end, Math.max(base, percent));
19369 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19370 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19371 }
19372
19373 function updateWizardProgress() {
19374 updateScrollProgress();
19375 }
19376
19377 var stepDescriptions = [
19378 "Choose a project folder, apply scope filters, and preview which files will be counted.",
19379 "Configure how mixed code-plus-comment lines and docstrings are classified.",
19380 "Pick your output formats, scan preset, and where reports are saved.",
19381 "Review all settings and launch the analysis."
19382 ];
19383
19384 function updateStepNav(step) {
19385 var infoLabel = document.getElementById("step-nav-info-label");
19386 var infoDesc = document.getElementById("step-nav-info-desc");
19387 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19388 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
19389 }
19390
19391 function updateSidebarSummary() {
19392 var sumPath = document.getElementById("sum-path");
19393 var sumPreset = document.getElementById("sum-preset");
19394 var sumOutput = document.getElementById("sum-output");
19395 var sidebarSummary = document.getElementById("sidebar-summary");
19396 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19397 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
19398 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19399 if (sumPath) sumPath.textContent = pathVal || "\u2014";
19400 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19401 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19402 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19403 }
19404
19405 function setStep(step, pushHistory) {
19406 currentStep = step;
19407 stepPanels.forEach(function (panel) {
19408 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19409 });
19410 stepButtons.forEach(function (button) {
19411 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19412 });
19413 var layoutEl = document.querySelector(".layout");
19414 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19415 updateWizardProgress();
19416 updateStepNav(step);
19417 stepButtons.forEach(function(btn) {
19418 var t = Number(btn.getAttribute("data-step-target"));
19419 btn.classList.toggle("done", t < step);
19420 });
19421 updateSidebarSummary();
19422
19423 if (pushHistory !== false) {
19424 try {
19425 history.pushState({ wizardStep: step }, "", "#step" + step);
19426 } catch (e) {}
19427 }
19428
19429 window.scrollTo({ top: 0, behavior: "instant" });
19430 }
19431
19432 window.addEventListener("popstate", function (e) {
19433 if (e.state && e.state.wizardStep) {
19434 setStep(e.state.wizardStep, false);
19435 } else {
19436 var hashMatch = location.hash.match(/^#step([1-4])$/);
19437 if (hashMatch) setStep(Number(hashMatch[1]), false);
19438 }
19439 });
19440
19441 function inferTitleFromPath(value) {
19442 if (!value) return "project";
19443 var cleaned = value.replace(/[\/\\]+$/, "");
19444 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19445 return parts.length ? parts[parts.length - 1] : value;
19446 }
19447
19448 function updateReportTitleFromPath() {
19449 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19450 if (!reportTitleTouched) {
19451 reportTitleInput.value = inferred;
19452 }
19453 var title = reportTitleInput.value || inferred;
19454 if (liveReportTitle) liveReportTitle.textContent = title;
19455 if (reportTitlePreview) reportTitlePreview.textContent = title;
19456 document.title = "OxideSLOC | " + title;
19457
19458 var projectPath = (pathInput.value || "").trim();
19459 if (navProjectPill && navProjectTitle) {
19460 if (projectPath.length > 0) {
19461 navProjectTitle.textContent = inferred;
19462 navProjectPill.classList.add("visible");
19463 } else {
19464 navProjectTitle.textContent = "";
19465 navProjectPill.classList.remove("visible");
19466 }
19467 }
19468 }
19469
19470 function updateMixedPolicyUI() {
19471 var key = mixedLinePolicy.value || "code_only";
19472 var info = mixedPolicyInfo[key];
19473 document.getElementById("mixed-policy-description").textContent = info.description;
19474 document.getElementById("mixed-policy-example").textContent = info.example;
19475 }
19476
19477 function updatePythonDocstringUI() {
19478 var checked = !!pythonDocstrings.checked;
19479 document.getElementById("python-docstring-example").textContent = checked
19480 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
19481 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
19482 document.getElementById("python-docstring-live-help").textContent = checked
19483 ? "Enabled: docstrings contribute to comment-style totals."
19484 : "Disabled: docstrings are not counted as comment content.";
19485 }
19486
19487 function renderPresetChips(targetId, chips) {
19488 var target = document.getElementById(targetId);
19489 if (!target) return;
19490 target.innerHTML = (chips || []).map(function (chip) {
19491 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19492 }).join('');
19493 }
19494
19495 function updatePresetDescriptions() {
19496 var scanInfo = scanPresetInfo[scanPreset.value];
19497 if (!scanInfo) return;
19498 document.getElementById("scan-preset-description").textContent = scanInfo.description;
19499 document.getElementById("scan-preset-example").textContent = scanInfo.example;
19500 document.getElementById("scan-preset-note").textContent = scanInfo.note;
19501 renderPresetChips("scan-preset-summary", scanInfo.chips);
19502 }
19503
19504 function applyScanPreset() {
19505 var info = scanPresetInfo[scanPreset.value];
19506 if (!info || !info.apply) return;
19507 mixedLinePolicy.value = info.apply.mixed;
19508 pythonDocstrings.checked = !!info.apply.docstrings;
19509 document.getElementById("generated_file_detection").value = info.apply.generated;
19510 document.getElementById("minified_file_detection").value = info.apply.minified;
19511 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19512 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19513 document.getElementById("binary_file_behavior").value = info.apply.binary;
19514 updateMixedPolicyUI();
19515 updatePythonDocstringUI();
19516 }
19517
19518 function updateReview() {
19519 var scanSummary = document.getElementById("review-scan-summary");
19520 var countSummary = document.getElementById("review-count-summary");
19521 var artifactSummary = document.getElementById("review-artifact-summary");
19522 var outputSummary = document.getElementById("review-output-summary");
19523 var previewSummary = document.getElementById("review-preview-summary");
19524 var readinessSummary = document.getElementById("review-readiness-summary");
19525 var includeText = document.getElementById("include_globs").value.trim();
19526 var excludeText = document.getElementById("exclude_globs").value.trim();
19527 var sidePathPreview = document.getElementById("side-path-preview");
19528 var sideOutputPreview = document.getElementById("side-output-preview");
19529 var sideTitlePreview = document.getElementById("side-title-preview");
19530
19531 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19532 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19533 if (sideTitlePreview) {
19534 var rt = document.getElementById("report_title");
19535 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19536 }
19537
19538 scanSummary.innerHTML = ""
19539 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19540 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19541 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19542
19543 countSummary.innerHTML = ""
19544 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19545 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19546 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19547 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19548 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19549 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19550 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19551 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19552
19553 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19554
19555 outputSummary.innerHTML = ""
19556 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19557 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19558
19559 if (previewSummary) {
19560 if (GIT_MODE) {
19561 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>';
19562 } else {
19563 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19564 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19565 var statMap = {};
19566 statButtons.forEach(function (button) {
19567 var valueNode = button.querySelector('.scope-stat-value');
19568 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19569 });
19570 previewSummary.innerHTML = ''
19571 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19572 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19573 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19574 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19575 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19576 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19577
19578 if (readinessSummary) {
19579 readinessSummary.innerHTML = ''
19580 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19581 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19582 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19583 }
19584 } // end else (non-GIT_MODE)
19585 }
19586 }
19587
19588 function escapeHtml(value) {
19589 return String(value)
19590 .replace(/&/g, "&")
19591 .replace(/</g, "<")
19592 .replace(/>/g, ">")
19593 .replace(/"/g, """)
19594 .replace(/'/g, "'");
19595 }
19596
19597 function isPythonVisible() {
19598 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19599 }
19600
19601 function syncPythonVisibility() {
19602 var html = previewPanel.textContent || "";
19603 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19604 pythonWraps.forEach(function (node) {
19605 node.classList.toggle("hidden", !hasPython);
19606 });
19607 }
19608
19609 function attachPreviewInteractions() {
19610 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19611 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19612 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19613 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19614 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19615 var searchInput = previewPanel.querySelector("#explorer-search");
19616 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19617 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19618 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19619 var activeFilter = "all";
19620 var activeLanguage = "";
19621 var searchTerm = "";
19622 var currentSortKey = null;
19623 var currentSortOrder = "asc";
19624 var childRows = {};
19625
19626 rows.forEach(function (row) {
19627 var parentId = row.getAttribute("data-parent-id") || "";
19628 var rowId = row.getAttribute("data-row-id") || "";
19629 if (!childRows[parentId]) childRows[parentId] = [];
19630 childRows[parentId].push(rowId);
19631 });
19632
19633 function rowById(id) {
19634 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19635 }
19636
19637 function hasCollapsedAncestor(row) {
19638 var parentId = row.getAttribute("data-parent-id");
19639 while (parentId) {
19640 var parent = rowById(parentId);
19641 if (!parent) break;
19642 if (parent.getAttribute("data-expanded") === "false") return true;
19643 parentId = parent.getAttribute("data-parent-id");
19644 }
19645 return false;
19646 }
19647
19648 function updateToggleGlyph(row) {
19649 var toggle = row.querySelector(".tree-toggle");
19650 if (!toggle) return;
19651 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19652 }
19653
19654 function rowSortValue(row, key) {
19655 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19656 }
19657
19658 function updateSortButtons() {
19659 sortButtons.forEach(function (button) {
19660 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19661 var indicator = button.querySelector(".tree-sort-indicator");
19662 button.classList.toggle("active", isActive);
19663 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19664 if (indicator) {
19665 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19666 }
19667 });
19668 }
19669
19670 function sortSiblingRows() {
19671 if (!treeContainer) {
19672 updateSortButtons();
19673 return;
19674 }
19675
19676 var rowMap = {};
19677 var childrenMap = {};
19678 rows.forEach(function (row) {
19679 var rowId = row.getAttribute("data-row-id");
19680 var parentId = row.getAttribute("data-parent-id") || "";
19681 rowMap[rowId] = row;
19682 if (!childrenMap[parentId]) childrenMap[parentId] = [];
19683 childrenMap[parentId].push(rowId);
19684 });
19685
19686 Object.keys(childrenMap).forEach(function (parentId) {
19687 if (!parentId) return;
19688 childrenMap[parentId].sort(function (a, b) {
19689 var rowA = rowMap[a];
19690 var rowB = rowMap[b];
19691 if (!currentSortKey) {
19692 return Number(a) - Number(b);
19693 }
19694 var valueA = rowSortValue(rowA, currentSortKey);
19695 var valueB = rowSortValue(rowB, currentSortKey);
19696 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19697 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19698 var fallbackA = rowSortValue(rowA, "name");
19699 var fallbackB = rowSortValue(rowB, "name");
19700 if (fallbackA < fallbackB) return -1;
19701 if (fallbackA > fallbackB) return 1;
19702 return Number(a) - Number(b);
19703 });
19704 });
19705
19706 var orderedIds = [];
19707 function pushChildren(parentId) {
19708 (childrenMap[parentId] || []).forEach(function (childId) {
19709 orderedIds.push(childId);
19710 pushChildren(childId);
19711 });
19712 }
19713
19714 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19715 orderedIds.push(topId);
19716 pushChildren(topId);
19717 });
19718
19719 orderedIds.forEach(function (id) {
19720 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19721 });
19722 updateSortButtons();
19723 }
19724
19725 function updateLanguageButtons() {
19726 languageButtons.forEach(function (button) {
19727 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19728 var isActive = languageValue === activeLanguage;
19729 button.classList.toggle("active", isActive);
19730 });
19731 }
19732
19733 function rowSelfMatches(row) {
19734 var kind = row.getAttribute("data-kind");
19735 var status = row.getAttribute("data-status");
19736 var language = (row.getAttribute("data-language") || "").toLowerCase();
19737 var name = row.getAttribute("data-name-lower") || "";
19738 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19739 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19740 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19741 var passesLanguage = !activeLanguage || language === activeLanguage;
19742 return passesFilter && passesSearch && passesLanguage;
19743 }
19744
19745 function hasMatchingDescendant(rowId) {
19746 return (childRows[rowId] || []).some(function (childId) {
19747 var childRow = rowById(childId);
19748 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19749 });
19750 }
19751
19752 function rowMatches(row) {
19753 if (rowSelfMatches(row)) return true;
19754 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19755 }
19756
19757 function resetViewState() {
19758 activeFilter = "all";
19759 activeLanguage = "";
19760 searchTerm = "";
19761 currentSortKey = null;
19762 currentSortOrder = "asc";
19763 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19764 if (searchInput) searchInput.value = "";
19765 if (filterSelect) filterSelect.value = "all";
19766 updateLanguageButtons();
19767 }
19768
19769 function applyVisibility() {
19770 rows.forEach(function (row) {
19771 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19772 row.classList.toggle("hidden-by-filter", !visible);
19773 row.style.display = visible ? "grid" : "none";
19774 });
19775 buttons.forEach(function (button) {
19776 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19777 });
19778 if (filterSelect) filterSelect.value = activeFilter;
19779 }
19780
19781 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19782 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19783 var originalStats = {};
19784 buttons.forEach(function (btn) {
19785 var f = btn.getAttribute('data-filter');
19786 var v = btn.querySelector('.scope-stat-value');
19787 if (f && v) originalStats[f] = v.textContent;
19788 });
19789
19790 function applySubmoduleStats(statsJson) {
19791 try {
19792 var s = JSON.parse(statsJson);
19793 buttons.forEach(function (btn) {
19794 var f = btn.getAttribute('data-filter');
19795 var v = btn.querySelector('.scope-stat-value');
19796 if (!v) return;
19797 if (f === 'dir') v.textContent = s.dirs;
19798 else if (f === 'file') v.textContent = s.files;
19799 else if (f === 'supported') v.textContent = s.supported;
19800 else if (f === 'skipped') v.textContent = s.skipped;
19801 else if (f === 'unsupported') v.textContent = s.unsupported;
19802 });
19803 } catch (e) {}
19804 }
19805
19806 function restoreBaseRepoStats() {
19807 buttons.forEach(function (btn) {
19808 var f = btn.getAttribute('data-filter');
19809 var v = btn.querySelector('.scope-stat-value');
19810 if (v && originalStats[f]) v.textContent = originalStats[f];
19811 });
19812 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19813 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19814 }
19815
19816 submoduleChips.forEach(function (chip) {
19817 chip.addEventListener('click', function () {
19818 var statsJson = chip.getAttribute('data-sub-stats');
19819 if (!statsJson) return;
19820 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19821 chip.classList.add('active');
19822 applySubmoduleStats(statsJson);
19823 if (baseRepoBtn) baseRepoBtn.style.display = '';
19824 });
19825 });
19826
19827 if (baseRepoBtn) {
19828 baseRepoBtn.addEventListener('click', function () {
19829 restoreBaseRepoStats();
19830 resetViewState();
19831 sortSiblingRows();
19832 applyVisibility();
19833 });
19834 }
19835
19836 buttons.forEach(function (button) {
19837 button.addEventListener("click", function () {
19838 var filterValue = button.getAttribute("data-filter") || "all";
19839 if (filterValue === "reset-view") {
19840 restoreBaseRepoStats();
19841 resetViewState();
19842 sortSiblingRows();
19843 applyVisibility();
19844 return;
19845 }
19846 activeFilter = filterValue;
19847 applyVisibility();
19848 });
19849 });
19850
19851 rows.forEach(function (row) {
19852 updateToggleGlyph(row);
19853 var toggle = row.querySelector(".tree-toggle");
19854 if (toggle) {
19855 toggle.addEventListener("click", function () {
19856 var expanded = row.getAttribute("data-expanded") !== "false";
19857 row.setAttribute("data-expanded", expanded ? "false" : "true");
19858 updateToggleGlyph(row);
19859 applyVisibility();
19860 });
19861 }
19862 });
19863
19864 actionButtons.forEach(function (button) {
19865 button.addEventListener("click", function () {
19866 var action = button.getAttribute("data-explorer-action");
19867 if (action === "expand-all") {
19868 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19869 } else if (action === "collapse-all") {
19870 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19871 } else if (action === "clear-filters") {
19872 resetViewState();
19873 }
19874 sortSiblingRows();
19875 applyVisibility();
19876 });
19877 });
19878
19879 if (filterSelect) {
19880 filterSelect.addEventListener("change", function () {
19881 activeFilter = filterSelect.value || "all";
19882 applyVisibility();
19883 });
19884 }
19885
19886 languageButtons.forEach(function (button) {
19887 button.addEventListener("click", function () {
19888 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19889 updateLanguageButtons();
19890 applyVisibility();
19891 });
19892 });
19893
19894 sortButtons.forEach(function (button) {
19895 button.addEventListener("click", function () {
19896 var sortKey = button.getAttribute("data-sort-key");
19897 if (currentSortKey === sortKey) {
19898 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
19899 } else {
19900 currentSortKey = sortKey;
19901 currentSortOrder = "asc";
19902 }
19903 sortSiblingRows();
19904 applyVisibility();
19905 });
19906 });
19907
19908 if (searchInput) {
19909 searchInput.addEventListener("input", function () {
19910 searchTerm = searchInput.value.trim().toLowerCase();
19911 applyVisibility();
19912 });
19913 }
19914
19915 updateLanguageButtons();
19916 sortSiblingRows();
19917 applyVisibility();
19918 }
19919
19920 function loadPreview() {
19921 if (!previewPanel || !pathInput) return;
19922 if (GIT_MODE) {
19923 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>';
19924 setPreviewLoading(false);
19925 return;
19926 }
19927 var path = pathInput.value.trim();
19928 var zeroWarn = document.getElementById('zero-files-warning');
19929 if (!path) {
19930 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
19931 if (zeroWarn) zeroWarn.style.display = 'none';
19932 setPreviewLoading(false);
19933 return;
19934 }
19935 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
19936 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
19937 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
19938 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
19939 var myGen = ++_previewGen;
19940 var _prevMsgs = [
19941 'Scanning directory structure\u2026',
19942 'Detecting file types\u2026',
19943 'Applying include / exclude filters\u2026',
19944 'Estimating file counts\u2026',
19945 'Building scope preview\u2026',
19946 'Almost there\u2026'
19947 ];
19948 var _prevMsgIdx = 0;
19949 var _prevStart = Date.now();
19950 previewPanel.innerHTML =
19951 '<div class="preview-loading">' +
19952 '<div class="preview-spinner"></div>' +
19953 '<div class="preview-loading-text">' +
19954 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
19955 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
19956 '</div></div>';
19957 var _sizeTextEl = document.getElementById('project-size-text');
19958 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
19959 window._previewInterval = setInterval(function() {
19960 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
19961 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
19962 var ml = document.getElementById('plm');
19963 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
19964 }, 1500);
19965 window._previewElapsedTimer = setInterval(function() {
19966 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
19967 var el = document.getElementById('ple');
19968 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
19969 }, 1000);
19970 setPreviewLoading(true);
19971 var previewUrl = "/preview?path=" + encodeURIComponent(path)
19972 + "&include_globs=" + encodeURIComponent(includeValue)
19973 + "&exclude_globs=" + encodeURIComponent(excludeValue);
19974 fetch(previewUrl)
19975 .then(function (response) { return response.text(); })
19976 .then(function (html) {
19977 if (myGen !== _previewGen) return;
19978 clearInterval(window._previewInterval); window._previewInterval = null;
19979 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19980 setPreviewLoading(false);
19981 previewPanel.innerHTML = html;
19982 attachPreviewInteractions();
19983 syncPythonVisibility();
19984 updateReview();
19985 setTimeout(collapseLanguagePills, 50);
19986 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
19987 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
19988 var sizeText = document.getElementById('project-size-text');
19989 var sizeBtn = document.getElementById('project-size-btn');
19990 // In server mode with upload sizes available, keep the compressed/original pair.
19991 if (SERVER_MODE && window._lastUploadSizes) {
19992 var us = window._lastUploadSizes;
19993 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
19994 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
19995 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
19996 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
19997 } else if (sizeText && projectSize) {
19998 sizeText.textContent = 'Project size: ' + projectSize;
19999 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20000 } else if (sizeText) {
20001 sizeText.textContent = 'Project size: \u2014';
20002 }
20003 if (zeroWarn) {
20004 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20005 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20006 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20007 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20008 if (supportedCount === 0 && fileCount > 0) {
20009 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).';
20010 zeroWarn.style.display = '';
20011 } else {
20012 zeroWarn.style.display = 'none';
20013 }
20014 }
20015 })
20016 .catch(function (err) {
20017 if (myGen !== _previewGen) return;
20018 clearInterval(window._previewInterval); window._previewInterval = null;
20019 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20020 setPreviewLoading(false);
20021 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20022 });
20023 }
20024
20025 function pickDirectory(targetInput, kind) {
20026 if (!targetInput) {
20027 showBannerToast("Directory picker: input element not found.", true);
20028 return;
20029 }
20030 if (SERVER_MODE) {
20031 if (kind === 'output') {
20032 showBannerToast(
20033 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20034 false,
20035 { top: true, icon: '\u{1F4C1}' }
20036 );
20037 return;
20038 }
20039 var inputEl = kind === 'coverage'
20040 ? document.getElementById('cov-upload-input')
20041 : document.getElementById('dir-upload-input');
20042 if (!inputEl) return;
20043 inputEl.onchange = function () {
20044 var files = inputEl.files;
20045 if (!files || files.length === 0) return;
20046 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20047 if (browseBtn) browseBtn.disabled = true;
20048
20049 function fileToBase64(file) {
20050 return new Promise(function (resolve, reject) {
20051 var reader = new FileReader();
20052 reader.onload = function () {
20053 var b64 = reader.result.split(',')[1];
20054 resolve(b64);
20055 };
20056 reader.onerror = reject;
20057 reader.readAsDataURL(file);
20058 });
20059 }
20060
20061 if (kind === 'coverage') {
20062 var f = files[0];
20063 if (previewPanel && targetInput === pathInput)
20064 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20065 fileToBase64(f).then(function (b64) {
20066 return fetch('/api/upload-file', {
20067 method: 'POST',
20068 headers: { 'Content-Type': 'application/json' },
20069 body: JSON.stringify({ filename: f.name, content: b64 })
20070 }).then(function (r) { return r.json(); });
20071 })
20072 .then(function (d) {
20073 if (d && d.tmp_path) {
20074 if (coverageInput) coverageInput.value = d.tmp_path;
20075 setCovStatus('idle');
20076 } else if (d && d.error) { showBannerToast(d.error, true); }
20077 })
20078 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20079 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20080 } else {
20081 // ── Filter to source-code files only ─────────────────────────
20082 // Binary, generated, and dependency files (node_modules, .git,
20083 // build artifacts) are skipped so they are never uploaded.
20084 var CODE_EXTS = new Set([
20085 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20086 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20087 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20088 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20089 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20090 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20091 'tf','hcl','proto','thrift','avsc','graphql','gql'
20092 ]);
20093 var codeFiles = [];
20094 for (var i = 0; i < files.length; i++) {
20095 var f = files[i];
20096 var name = f.name;
20097 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20098 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20099 codeFiles.push(f); continue;
20100 }
20101 var dot = name.lastIndexOf('.');
20102 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20103 }
20104 // Collect specific .git metadata files for server-side git detection.
20105 // These have no source extension so they are excluded by the loop above,
20106 // but the server needs them to read branch/commit/author without running git.
20107 var gitMetaFiles = [];
20108 for (var i = 0; i < files.length; i++) {
20109 var f = files[i];
20110 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20111 var gitIdx = rp.indexOf('/.git/');
20112 if (gitIdx < 0) continue;
20113 var gitRel = rp.slice(gitIdx + 1);
20114 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20115 gitRel === '.git/logs/HEAD' ||
20116 gitRel.startsWith('.git/refs/heads/') ||
20117 gitRel.startsWith('.git/refs/tags/')) {
20118 gitMetaFiles.push(f);
20119 }
20120 }
20121 var uploadFiles = codeFiles.concat(gitMetaFiles);
20122 var total = files.length;
20123 var kept = codeFiles.length;
20124 if (kept === 0) {
20125 if (previewPanel && targetInput === pathInput)
20126 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20127 if (browseBtn) browseBtn.disabled = false;
20128 inputEl.value = '';
20129 return;
20130 }
20131
20132 // ── Helper: apply upload result to UI ────────────────────────
20133 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20134 function applyUploadResult(tmpPath, sizes) {
20135 targetInput.value = tmpPath;
20136 scrollInputToEnd(targetInput);
20137 if (sizes && SERVER_MODE) {
20138 window._lastUploadSizes = sizes;
20139 // Immediately show both sizes before preview loads.
20140 var sizeText = document.getElementById('project-size-text');
20141 var sizeBtn = document.getElementById('project-size-btn');
20142 if (sizeText) {
20143 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20144 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20145 }
20146 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20147 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20148 }
20149 if (targetInput === pathInput) {
20150 updateReportTitleFromPath();
20151 autoSetOutputDir(tmpPath);
20152 fetchProjectHistory(tmpPath);
20153 loadPreview();
20154 suggestCoverageFile(tmpPath);
20155 }
20156 updateReview();
20157 if (browseBtn) browseBtn.disabled = false;
20158 inputEl.value = '';
20159 }
20160
20161 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20162 if (typeof CompressionStream !== 'undefined') {
20163 if (previewPanel && targetInput === pathInput)
20164 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20165
20166 // Build a minimal POSIX ustar tar header for a single file entry.
20167 function buildUstarHeader(filePath, fileSize) {
20168 var BLOCK = 512;
20169 var hdr = new Uint8Array(BLOCK);
20170 var enc = new TextEncoder();
20171 function wStr(off, len, s) {
20172 var b = enc.encode(s);
20173 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20174 }
20175 function wOct(off, len, val) {
20176 var s = val.toString(8);
20177 while (s.length < len - 1) s = '0' + s;
20178 wStr(off, len, s + '\0');
20179 }
20180 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20181 var name = filePath, prefix = '';
20182 if (filePath.length > 99) {
20183 var split = filePath.lastIndexOf('/', 154);
20184 if (split > 0 && filePath.length - split - 1 <= 99) {
20185 prefix = filePath.substring(0, split);
20186 name = filePath.substring(split + 1);
20187 } else { name = filePath.substring(0, 99); }
20188 }
20189 wStr(0, 100, name); // name
20190 wOct(100, 8, 0o000644); // mode
20191 wOct(108, 8, 0); // uid
20192 wOct(116, 8, 0); // gid
20193 wOct(124, 12, fileSize); // size
20194 wOct(136, 12, 0); // mtime (epoch)
20195 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20196 hdr[156] = 48; // type flag '0' = regular file
20197 wStr(157, 100, ''); // linkname
20198 wStr(257, 6, 'ustar'); // magic
20199 wStr(263, 2, '00'); // version
20200 wStr(265, 32, ''); // uname
20201 wStr(297, 32, ''); // gname
20202 wOct(329, 8, 0); // devmajor
20203 wOct(337, 8, 0); // devminor
20204 wStr(345, 155, prefix); // prefix
20205 // Compute checksum (sum of all bytes, placeholder = 32).
20206 var chk = 0;
20207 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20208 var cs = chk.toString(8);
20209 while (cs.length < 6) cs = '0' + cs;
20210 wStr(148, 8, cs + '\0 ');
20211 return hdr;
20212 }
20213
20214 // Build tar.gz one file at a time, piping through CompressionStream.
20215 // RAM usage = compressed output buffer + one file at a time.
20216 (async function () {
20217 try {
20218 var BLOCK = 512;
20219 var cs = new CompressionStream('gzip');
20220 var writer = cs.writable.getWriter();
20221 var chunks = [];
20222 var reader = cs.readable.getReader();
20223 var collecting = (async function () {
20224 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20225 })();
20226
20227 for (var i = 0; i < uploadFiles.length; i++) {
20228 var file = uploadFiles[i];
20229 var path = file.webkitRelativePath || file.name;
20230 var buf = await file.arrayBuffer();
20231 var data = new Uint8Array(buf);
20232 // Header block
20233 await writer.write(buildUstarHeader(path, data.length));
20234 // Data padded to 512-byte boundary
20235 if (data.length > 0) {
20236 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20237 var block = new Uint8Array(padded);
20238 block.set(data);
20239 await writer.write(block);
20240 }
20241 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20242 if (previewPanel && targetInput === pathInput)
20243 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20244 }
20245 }
20246 // End-of-archive: two 512-byte zero blocks
20247 await writer.write(new Uint8Array(BLOCK * 2));
20248 await writer.close();
20249 await collecting;
20250
20251 var blob = new Blob(chunks, { type: 'application/gzip' });
20252 var sizeMB = (blob.size / 1048576).toFixed(1);
20253 if (previewPanel && targetInput === pathInput)
20254 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20255
20256 var resp = await fetch('/api/upload-tarball', {
20257 method: 'POST',
20258 headers: { 'Content-Type': 'application/gzip' },
20259 body: blob
20260 });
20261 var d = await resp.json();
20262 if (d && d.tmp_path) {
20263 applyUploadResult(d.tmp_path, {
20264 compressed_bytes: d.compressed_bytes || 0,
20265 original_bytes: d.original_bytes || 0
20266 });
20267 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20268 } catch (e) {
20269 showBannerToast('Upload failed: ' + String(e), true);
20270 if (browseBtn) browseBtn.disabled = false;
20271 inputEl.value = '';
20272 }
20273 })();
20274
20275 } else {
20276 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20277 // Used only on browsers that lack CompressionStream (pre-2023).
20278 var BATCH = 200;
20279 var batches = [];
20280 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20281 var totalBatches = batches.length;
20282 if (previewPanel && targetInput === pathInput)
20283 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20284
20285 function sendBatch(idx, currentUploadId, lastTmpPath) {
20286 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20287 if (previewPanel && targetInput === pathInput && totalBatches > 1)
20288 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20289 Promise.all(batches[idx].map(function (file) {
20290 return fileToBase64(file).then(function (b64) {
20291 return { path: file.webkitRelativePath || file.name, content: b64 };
20292 });
20293 })).then(function (fileList) {
20294 var body = { files: fileList };
20295 if (currentUploadId) body.upload_id = currentUploadId;
20296 return fetch('/api/upload-directory', {
20297 method: 'POST', headers: { 'Content-Type': 'application/json' },
20298 body: JSON.stringify(body)
20299 }).then(function (r) { return r.json(); });
20300 }).then(function (d) {
20301 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20302 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20303 }).catch(function (e) {
20304 showBannerToast('Upload failed: ' + String(e), true);
20305 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20306 });
20307 }
20308 sendBatch(0, null, '');
20309 }
20310 }
20311 };
20312 inputEl.click();
20313 return;
20314 }
20315
20316 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20317 if (browseButton) browseButton.disabled = true;
20318
20319 if (previewPanel && targetInput === pathInput) {
20320 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20321 }
20322
20323 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
20324 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20325 .then(function (data) {
20326 if (data && data.selected_path) {
20327 targetInput.value = data.selected_path;
20328 scrollInputToEnd(targetInput);
20329
20330 if (targetInput === pathInput) {
20331 updateReportTitleFromPath();
20332 autoSetOutputDir(data.selected_path);
20333 fetchProjectHistory(data.selected_path);
20334 loadPreview();
20335 suggestCoverageFile(data.selected_path);
20336 }
20337
20338 updateReview();
20339 } else if (targetInput === pathInput) {
20340 loadPreview();
20341 }
20342 })
20343 .catch(function () {
20344 window.alert("Directory picker request failed.");
20345 if (previewPanel && targetInput === pathInput) {
20346 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20347 }
20348 })
20349 .finally(function () {
20350 if (browseButton) browseButton.disabled = false;
20351 });
20352 }
20353
20354 if (themeToggle) {
20355 themeToggle.addEventListener("click", function () {
20356 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20357 applyTheme(nextTheme);
20358 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20359 });
20360 }
20361
20362 stepButtons.forEach(function (button) {
20363 button.addEventListener("click", function () {
20364 var target = Number(button.getAttribute("data-step-target"));
20365 // Block jumping forward off step 1 while the preview / upload is running.
20366 if (previewLoading && currentStep === 1 && target > 1) return;
20367 setStep(target);
20368 });
20369 });
20370
20371 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20372 button.addEventListener("click", function () {
20373 var target = Number(button.getAttribute("data-step-target")) || 1;
20374 if (previewLoading && currentStep === 1 && target > 1) return;
20375 setStep(target);
20376 });
20377 });
20378
20379 // True when the project path is untouched from the bundled sample default.
20380 function isDefaultSamplePath() {
20381 return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20382 }
20383
20384 var defaultPathOverlay = document.getElementById("default-path-overlay");
20385 function closeDefaultPathModal() {
20386 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20387 }
20388 function openDefaultPathModal() {
20389 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20390 }
20391
20392 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20393 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20394 // that borrow the .next-step style class but carry no data-next target).
20395 if (!button.hasAttribute("data-next")) return;
20396 button.addEventListener("click", function () {
20397 // Guard step 1 → 2: block while the scope preview / upload is still running.
20398 if (button.getAttribute("data-next") === "2" && previewLoading) return;
20399 // Guard step 1 → 2: warn when the project path is still the sample default.
20400 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20401 openDefaultPathModal();
20402 return;
20403 }
20404 updateReview();
20405 setStep(Number(button.getAttribute("data-next")));
20406 });
20407 });
20408
20409 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20410 if (!button.hasAttribute("data-prev")) return;
20411 button.addEventListener("click", function () {
20412 setStep(Number(button.getAttribute("data-prev")));
20413 });
20414 });
20415
20416 // Default-sample-path confirmation modal wiring.
20417 var defaultPathProceed = document.getElementById("default-path-proceed");
20418 if (defaultPathProceed) {
20419 defaultPathProceed.addEventListener("click", function () {
20420 closeDefaultPathModal();
20421 updateReview();
20422 setStep(2);
20423 });
20424 }
20425 var defaultPathCancel = document.getElementById("default-path-cancel");
20426 if (defaultPathCancel) {
20427 defaultPathCancel.addEventListener("click", function () {
20428 closeDefaultPathModal();
20429 if (pathInput) { pathInput.focus(); pathInput.select(); }
20430 });
20431 }
20432 if (defaultPathOverlay) {
20433 defaultPathOverlay.addEventListener("click", function (e) {
20434 if (e.target === defaultPathOverlay) closeDefaultPathModal();
20435 });
20436 }
20437 document.addEventListener("keydown", function (e) {
20438 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20439 closeDefaultPathModal();
20440 }
20441 });
20442
20443 document.addEventListener("keydown", function (e) {
20444 var tag = (document.activeElement || {}).tagName || "";
20445 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20446 if (e.altKey || e.ctrlKey || e.metaKey) return;
20447 if (e.key === "ArrowRight" && currentStep < 4) {
20448 if (currentStep === 1 && previewLoading) return;
20449 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20450 updateReview(); setStep(currentStep + 1);
20451 }
20452 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20453 });
20454
20455 if (useSamplePath) {
20456 useSamplePath.addEventListener("click", function () {
20457 pathInput.value = "tests/fixtures/basic";
20458 updateReportTitleFromPath();
20459 autoSetOutputDir("tests/fixtures/basic");
20460 loadPreview();
20461 suggestCoverageFile("tests/fixtures/basic");
20462 });
20463 }
20464
20465 if (useDefaultOutput) {
20466 useDefaultOutput.addEventListener("click", function () {
20467 delete outputDirInput.dataset.userEdited;
20468 autoSetOutputDir(pathInput ? pathInput.value : "");
20469 updateReview();
20470 });
20471 }
20472
20473 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20474 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20475
20476 // ── Drag-and-drop directory upload (server mode only) ─────────────────
20477 // Dropping a folder onto the path field bypasses Chrome's
20478 // "Upload X files to this site?" confirmation dialog.
20479 async function readDirRecursively(dirEntry, basePath) {
20480 var reader = dirEntry.createReader();
20481 var all = [];
20482 for (;;) {
20483 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20484 if (!batch.length) break;
20485 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20486 }
20487 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20488 var out = [];
20489 for (var i = 0; i < all.length; i++) {
20490 var sub = all[i];
20491 if (sub.isFile) {
20492 var f = await new Promise(function(res) { sub.file(res); });
20493 out.push({ file: f, path: basePath + '/' + sub.name });
20494 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20495 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20496 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20497 }
20498 }
20499 return out;
20500 }
20501
20502 function setupPathDropZone() {
20503 if (!SERVER_MODE || !pathInput) return;
20504 var CODE_EXTS = new Set([
20505 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20506 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20507 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20508 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20509 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20510 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20511 ]);
20512 pathInput.addEventListener('dragover', function(e) {
20513 e.preventDefault();
20514 pathInput.classList.add('drag-over');
20515 });
20516 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20517 pathInput.addEventListener('drop', function(e) {
20518 e.preventDefault();
20519 pathInput.classList.remove('drag-over');
20520 var items = e.dataTransfer.items;
20521 if (!items || !items.length) return;
20522 var dirEntry = null;
20523 for (var i = 0; i < items.length; i++) {
20524 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20525 if (entry && entry.isDirectory) { dirEntry = entry; break; }
20526 }
20527 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20528 var btn = browsePath;
20529 if (btn) btn.disabled = true;
20530 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20531
20532 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20533 var total = allEntries.length;
20534 var codeEntries = allEntries.filter(function(e) {
20535 var n = e.file.name;
20536 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20537 var dot = n.lastIndexOf('.');
20538 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20539 });
20540 var kept = codeEntries.length;
20541 if (kept === 0) {
20542 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20543 if (btn) btn.disabled = false; return;
20544 }
20545
20546 function finish(tmpPath, sizes) {
20547 pathInput.value = tmpPath;
20548 scrollInputToEnd(pathInput);
20549 if (sizes) {
20550 window._lastUploadSizes = sizes;
20551 var sizeText = document.getElementById('project-size-text');
20552 var sizeBtn = document.getElementById('project-size-btn');
20553 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20554 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20555 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20556 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20557 }
20558 updateReportTitleFromPath();
20559 autoSetOutputDir(tmpPath);
20560 fetchProjectHistory(tmpPath);
20561 loadPreview();
20562 suggestCoverageFile(tmpPath);
20563 updateReview();
20564 if (btn) btn.disabled = false;
20565 }
20566
20567 if (typeof CompressionStream === 'undefined') {
20568 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20569 if (btn) btn.disabled = false; return;
20570 }
20571
20572 try {
20573 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20574 var BLOCK = 512;
20575 var cs = new CompressionStream('gzip');
20576 var wtr = cs.writable.getWriter();
20577 var chunks = [];
20578 var rdr = cs.readable.getReader();
20579 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20580
20581 function buildHdr(fp, sz) {
20582 var hdr = new Uint8Array(BLOCK);
20583 var enc = new TextEncoder();
20584 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]; }
20585 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20586 var nm = fp, pfx = '';
20587 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); } }
20588 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20589 for (var i = 148; i < 156; i++) hdr[i] = 32;
20590 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);
20591 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20592 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20593 return hdr;
20594 }
20595
20596 for (var i = 0; i < codeEntries.length; i++) {
20597 var ce = codeEntries[i];
20598 var buf = await ce.file.arrayBuffer();
20599 var data = new Uint8Array(buf);
20600 await wtr.write(buildHdr(ce.path, data.length));
20601 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20602 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20603 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20604 }
20605 await wtr.write(new Uint8Array(BLOCK * 2));
20606 await wtr.close();
20607 await collecting;
20608
20609 var blob = new Blob(chunks, { type: 'application/gzip' });
20610 var sizeMB = (blob.size / 1048576).toFixed(1);
20611 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20612 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20613 var d = await resp.json();
20614 if (d && d.tmp_path) {
20615 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20616 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20617 } catch (err) {
20618 showBannerToast('Upload failed: ' + String(err), true);
20619 if (btn) btn.disabled = false;
20620 }
20621 }).catch(function(err) {
20622 showBannerToast('Could not read folder: ' + String(err), true);
20623 if (btn) btn.disabled = false;
20624 });
20625 });
20626 }
20627 setupPathDropZone();
20628 if (browseCoverage) {
20629 browseCoverage.addEventListener("click", function () {
20630 pickDirectory(coverageInput || pathInput, "coverage");
20631 });
20632 }
20633
20634 function setCovStatus(state, opts) {
20635 if (!covScanStatus) return;
20636 opts = opts || {};
20637 covScanStatus.className = "cov-scan-status cov-scan-" + state;
20638 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20639 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>';
20640 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>';
20641 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>';
20642 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>';
20643 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20644 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20645 if (state === "scanning") {
20646 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20647 } else if (state === "found") {
20648 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20649 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20650 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20651 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20652 } else if (state === "hint") {
20653 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20654 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
20655 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>';
20656 } else if (state === "none") {
20657 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20658 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
20659 }
20660 html += '</div></div>';
20661 covScanStatus.innerHTML = html;
20662 if (state === "found") {
20663 var useBtn = covScanStatus.querySelector(".cov-scan-use");
20664 if (useBtn) useBtn.addEventListener("click", function () {
20665 if (coverageInput) coverageInput.value = "";
20666 covAutoFilled = false;
20667 setCovStatus("idle");
20668 });
20669 }
20670 }
20671
20672 function suggestCoverageFile(projectPath) {
20673 if (!coverageInput || !covScanStatus) return;
20674 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20675 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20676 clearTimeout(coverageSuggestTimer);
20677 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20678 setCovStatus("scanning");
20679 coverageSuggestTimer = setTimeout(function () {
20680 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20681 .then(function (r) { return r.json(); })
20682 .then(function (d) {
20683 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20684 if (!d) { setCovStatus("none"); return; }
20685 if (d.found) {
20686 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20687 setCovStatus("found", { found: d.found, tool: d.tool });
20688 } else if (d.tool && d.hint) {
20689 setCovStatus("hint", { tool: d.tool, hint: d.hint });
20690 } else {
20691 setCovStatus("none");
20692 }
20693 })
20694 .catch(function () { setCovStatus("idle"); });
20695 }, 600);
20696 }
20697
20698 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20699
20700 if (coverageInput) coverageInput.addEventListener("input", function () {
20701 covAutoFilled = false;
20702 if (!this.value.trim()) setCovStatus("idle");
20703 });
20704
20705 // ── Language pill overflow: collapse to "+N more" chip ─────────────
20706 function collapseLanguagePills() {
20707 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20708 rows.forEach(function(row) {
20709 // Remove any previous overflow chip
20710 var prev = row.querySelector('.lang-overflow-chip');
20711 if (prev) prev.remove();
20712 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20713 pills.forEach(function(p) { p.style.display = ''; });
20714 if (!pills.length) return;
20715
20716 // Measure after restoring all pills
20717 var containerRight = row.getBoundingClientRect().right;
20718 var hidden = [];
20719 for (var i = pills.length - 1; i >= 1; i--) {
20720 var rect = pills[i].getBoundingClientRect();
20721 if (rect.right > containerRight + 2) {
20722 hidden.unshift(pills[i]);
20723 pills[i].style.display = 'none';
20724 } else {
20725 break;
20726 }
20727 }
20728
20729 if (hidden.length) {
20730 var chip = document.createElement('button');
20731 chip.type = 'button';
20732 chip.className = 'language-pill lang-overflow-chip';
20733 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20734 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20735 row.appendChild(chip);
20736 }
20737 });
20738 }
20739
20740 // Run after preview loads (preview panel populates language pills)
20741 var _origLoadPreviewCb = window.__previewLoaded;
20742 document.addEventListener('previewLoaded', collapseLanguagePills);
20743 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20744 setTimeout(collapseLanguagePills, 400);
20745
20746 // ── Project history & output dir auto-set ──────────────────────────
20747 var wsOutputRoot = document.getElementById("ws-output-root");
20748 var wsScanCount = document.getElementById("ws-scan-count");
20749 var wsLastScan = document.getElementById("ws-last-scan");
20750 var historyBadge = document.getElementById("path-history-badge");
20751 var historyTimer = null;
20752
20753 var wsOutputLink = document.getElementById("ws-output-link");
20754 function syncStripOutputRoot() {
20755 var val = outputDirInput ? outputDirInput.value : "";
20756 var display = val || "project/sloc";
20757 if (wsOutputRoot) wsOutputRoot.textContent = display;
20758 if (wsOutputLink) wsOutputLink.dataset.folder = val;
20759 }
20760
20761 function scrollInputToEnd(input) {
20762 if (!input) return;
20763 // Defer so the DOM has the new value before we measure scroll width.
20764 requestAnimationFrame(function () {
20765 input.scrollLeft = input.scrollWidth;
20766 input.selectionStart = input.selectionEnd = input.value.length;
20767 });
20768 }
20769
20770 function autoSetOutputDir(projectPath) {
20771 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20772 if (GIT_MODE && GIT_OUTPUT_DIR) {
20773 outputDirInput.value = GIT_OUTPUT_DIR;
20774 scrollInputToEnd(outputDirInput);
20775 syncStripOutputRoot();
20776 updateReview();
20777 return;
20778 }
20779 if (!projectPath || !projectPath.trim()) return;
20780 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20781 outputDirInput.value = cleaned + "/sloc";
20782 scrollInputToEnd(outputDirInput);
20783 syncStripOutputRoot();
20784 updateReview();
20785 }
20786
20787 var wsBranch = document.getElementById("ws-branch");
20788
20789 function fetchProjectHistory(projectPath) {
20790 if (!projectPath || !projectPath.trim()) {
20791 if (wsScanCount) wsScanCount.textContent = "\u2014";
20792 if (wsLastScan) wsLastScan.textContent = "\u2014";
20793 if (wsBranch) wsBranch.textContent = "\u2014";
20794 if (historyBadge) historyBadge.style.display = "none";
20795 return;
20796 }
20797 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20798 .then(function (r) { return r.ok ? r.json() : null; })
20799 .then(function (data) {
20800 if (!data) return;
20801 var countStr = data.scan_count > 0
20802 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20803 : "never";
20804 var tsStr = data.last_scan_timestamp
20805 ? data.last_scan_timestamp.replace(" UTC","")
20806 : "\u2014";
20807 if (wsScanCount) wsScanCount.textContent = countStr;
20808 if (wsLastScan) wsLastScan.textContent = tsStr;
20809 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
20810 if (data.scan_count > 0) {
20811 if (historyBadge) {
20812 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20813 historyBadge.textContent = data.scan_count + " previous scan" +
20814 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20815 "Last: " + (data.last_scan_timestamp || "\u2014") +
20816 " \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.";
20817 historyBadge.className = "path-history-badge found";
20818 historyBadge.style.display = "";
20819 }
20820 } else {
20821 if (historyBadge) historyBadge.style.display = "none";
20822 }
20823 })
20824 .catch(function () {});
20825 }
20826
20827 function onPathChange() {
20828 var val = pathInput ? pathInput.value : "";
20829 // Discard stale upload sizes when the user edits the path manually.
20830 window._lastUploadSizes = null;
20831 updateReportTitleFromPath();
20832 autoSetOutputDir(val);
20833 updateSidebarSummary();
20834 clearTimeout(historyTimer);
20835 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20836 if (previewTimer) clearTimeout(previewTimer);
20837 previewTimer = setTimeout(loadPreview, 280);
20838 suggestCoverageFile(val);
20839 }
20840
20841 if (pathInput) {
20842 pathInput.addEventListener("input", onPathChange);
20843 }
20844
20845 if (outputDirInput) {
20846 outputDirInput.addEventListener("input", function () {
20847 outputDirInput.dataset.userEdited = "1";
20848 syncStripOutputRoot();
20849 updateReview();
20850 });
20851 }
20852
20853 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20854 if (!node) return;
20855 node.addEventListener("input", function () {
20856 updateReview();
20857 if (previewTimer) clearTimeout(previewTimer);
20858 previewTimer = setTimeout(loadPreview, 280);
20859 });
20860 });
20861
20862 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20863 var node = document.getElementById(id);
20864 if (node) node.addEventListener("change", updateReview);
20865 });
20866
20867 if (reportTitleInput) {
20868 reportTitleInput.addEventListener("input", function () {
20869 reportTitleTouched = reportTitleInput.value.trim().length > 0;
20870 updateReportTitleFromPath();
20871 updateReview();
20872 });
20873 }
20874
20875 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20876 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20877 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20878 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20879
20880 if (coverageInput) {
20881 coverageInput.addEventListener("input", function () {
20882 if (coverageInput.value.trim()) setCovStatus("idle");
20883 });
20884 }
20885
20886 if (form && loading && submitButton) {
20887 form.addEventListener("submit", function (e) {
20888 e.preventDefault();
20889 submitButton.disabled = true;
20890 submitButton.textContent = "Scanning...";
20891 startAsyncAnalysis(new FormData(form));
20892 });
20893 }
20894
20895 function openPath(folder) {
20896 if (!folder) return;
20897 fetch('/open-path?path=' + encodeURIComponent(folder))
20898 .then(function (r) { return r.json(); })
20899 .then(function (d) {
20900 if (d && d.server_mode_disabled)
20901 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20902 })
20903 .catch(function () {});
20904 }
20905
20906 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
20907 btn.addEventListener('click', function () {
20908 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
20909 });
20910 });
20911
20912 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
20913 if (wsOutputLink) {
20914 wsOutputLink.addEventListener('click', function () {
20915 openPath(wsOutputLink.dataset.folder || '');
20916 });
20917 }
20918
20919 loadSavedTheme();
20920 updateMixedPolicyUI();
20921 updatePythonDocstringUI();
20922 applyScanPreset();
20923 updatePresetDescriptions();
20924 applyArtifactPreset();
20925 updateReview();
20926 updateScrollProgress(); // initialise bar to 0% (step 1)
20927 window.addEventListener("scroll", updateScrollProgress, { passive: true });
20928 onPathChange(); // seed output dir, history badge, and preview from initial path
20929 updateStepNav(1);
20930
20931 // Restore step from URL hash on initial load (e.g., back-forward cache)
20932 (function() {
20933 var hashMatch = location.hash.match(/^#step([1-4])$/);
20934 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
20935 })();
20936
20937 (function randomizeWatermarks() {
20938 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
20939 if (!wms.length) return;
20940 var placed = [];
20941 function tooClose(top, left) {
20942 for (var i = 0; i < placed.length; i++) {
20943 var dt = Math.abs(placed[i][0] - top);
20944 var dl = Math.abs(placed[i][1] - left);
20945 if (dt < 16 && dl < 12) return true;
20946 }
20947 return false;
20948 }
20949 function pick(leftBand) {
20950 for (var attempt = 0; attempt < 50; attempt++) {
20951 var top = Math.random() * 88 + 2;
20952 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20953 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20954 }
20955 var top = Math.random() * 88 + 2;
20956 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20957 placed.push([top, left]);
20958 return [top, left];
20959 }
20960 var half = Math.floor(wms.length / 2);
20961 wms.forEach(function (img, i) {
20962 var pos = pick(i < half);
20963 var size = Math.floor(Math.random() * 80 + 110);
20964 var rot = (Math.random() * 360).toFixed(1);
20965 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
20966 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;
20967 });
20968 })();
20969
20970 (function spawnCodeParticles() {
20971 var container = document.getElementById('code-particles');
20972 if (!container) return;
20973 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'];
20974 for (var i = 0; i < 38; i++) {
20975 (function(idx) {
20976 var el = document.createElement('span');
20977 el.className = 'code-particle';
20978 el.textContent = snippets[idx % snippets.length];
20979 var left = Math.random() * 94 + 2;
20980 var top = Math.random() * 88 + 6;
20981 var dur = (Math.random() * 10 + 9).toFixed(1);
20982 var delay = (Math.random() * 18).toFixed(1);
20983 var rot = (Math.random() * 26 - 13).toFixed(1);
20984 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20985 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';
20986 container.appendChild(el);
20987 })(i);
20988 }
20989 })();
20990 })();
20991 </script>
20992 <script nonce="{{ csp_nonce }}">
20993 (function () {
20994 var raw = {{ prefill_json|safe }};
20995 if (!raw || typeof raw !== 'object' || !raw.path) return;
20996 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
20997 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
20998 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
20999 setVal('path', raw.path || '');
21000 setVal('include_globs', raw.include_globs || '');
21001 setVal('exclude_globs', raw.exclude_globs || '');
21002 setVal('output_dir', raw.output_dir || '');
21003 setVal('report_title', raw.report_title || '');
21004 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21005 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21006 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21007 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21008 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21009 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21010 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21011 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21012 setChecked('generate_html', raw.generate_html !== false);
21013 setChecked('generate_pdf', !!raw.generate_pdf);
21014 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21015 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21016 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21017 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21018 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21019 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21020 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21021 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21022 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21023 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21024 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21025 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21026 // Trigger dynamic UI updates after pre-fill.
21027 setTimeout(function () {
21028 var pathEl = document.getElementById('path');
21029 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21030 var policyEl = document.getElementById('mixed_line_policy');
21031 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21032 }, 80);
21033 })();
21034 </script>
21035 <script nonce="{{ csp_nonce }}">
21036 (function(){
21037 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'}];
21038 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);});}
21039 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21040 function init(){
21041 var btn=document.getElementById('settings-btn');if(!btn)return;
21042 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21043 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>';
21044 document.body.appendChild(m);
21045 var g=document.getElementById('scheme-grid');
21046 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);});
21047 var cl=document.getElementById('settings-close');
21048 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);
21049 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');});
21050 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21051 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21052 }
21053 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21054 }());
21055 </script>
21056 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21057 <div class="wb-ftip-arrow"></div>
21058 <span id="wb-ftip-text"></span>
21059 </div>
21060 <script nonce="{{ csp_nonce }}">(function(){
21061 var tip=document.getElementById('wb-ftip');
21062 var txt=document.getElementById('wb-ftip-text');
21063 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21064 if(!tip||!txt)return;
21065 function pos(el){
21066 var r=el.getBoundingClientRect();
21067 tip.style.display='block';
21068 var tw=tip.offsetWidth;
21069 var lx=r.left+r.width/2-tw/2;
21070 if(lx<8)lx=8;
21071 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21072 tip.style.left=lx+'px';
21073 tip.style.top=(r.bottom+8)+'px';
21074 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';}
21075 }
21076 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21077 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21078 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21079 });
21080 window.addEventListener('blur',function(){tip.style.display='none';});
21081 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21082 })();
21083 (function(){
21084 function fixArtifactHintSpacing(){
21085 var grid=document.querySelector('.artifact-grid');
21086 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21087 }
21088 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21089 }());
21090 (function(){
21091 var dot=document.getElementById('status-dot');
21092 var pingEl=document.getElementById('server-ping-ms');
21093 var tipEl=document.getElementById('server-tip-ping');
21094 var fm=document.getElementById('footer-mode');
21095 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)';}}
21096 function doPing(){
21097 var t0=performance.now();
21098 fetch('/healthz',{cache:'no-store'})
21099 .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);})
21100 .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)';}});
21101 }
21102 doPing();
21103 setInterval(doPing,5000);
21104 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');}
21105 })();
21106 </script>
21107 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21108 <footer class="site-footer">
21109 local code analysis - metrics, history and reports
21110 · <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>
21111 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21112 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21113 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21114 · <a href="/api-docs" rel="noopener">REST API</a>
21115 </footer>
21116</body>
21117</html>
21118"##,
21119 ext = "html"
21120)]
21121struct IndexTemplate {
21122 version: &'static str,
21123 prefill_json: String,
21124 csp_nonce: String,
21125 git_repo: String,
21126 git_ref: String,
21127 git_label_json: String,
21128 git_output_dir_json: String,
21129 server_mode: bool,
21130}
21131
21132#[derive(Template)]
21135#[template(
21136 source = r##"
21137<!doctype html>
21138<html lang="en">
21139<head>
21140 <meta charset="utf-8">
21141 <meta name="viewport" content="width=device-width, initial-scale=1">
21142 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21143 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21144 <script type="application/ld+json">
21145 {
21146 "@context": "https://schema.org",
21147 "@type": "SoftwareApplication",
21148 "name": "oxide-sloc",
21149 "applicationCategory": "DeveloperApplication",
21150 "operatingSystem": "Windows, Linux",
21151 "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.",
21152 "softwareVersion": "{{ version }}",
21153 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21154 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21155 "url": "https://github.com/oxide-sloc/oxide-sloc",
21156 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21157 "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",
21158 "programmingLanguage": "Rust",
21159 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21160 }
21161 </script>
21162 <style nonce="{{ csp_nonce }}">
21163 :root {
21164 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21165 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21166 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21167 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21168 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21169 }
21170 body.dark-theme {
21171 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21172 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21173 }
21174 *{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;}
21175 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21176 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21177 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21178 .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;}
21179 @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));}}
21180 .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);}
21181 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21182 .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));}
21183 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21184 .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;}
21185 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21186 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21187 @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; } }
21188 .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;}
21189 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21190 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21191 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21192 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21193 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21194 .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;}
21195 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21196 .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);}
21197 .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;}
21198 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21199 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21200 .settings-modal-body{padding:14px 16px 16px;}
21201 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21202 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21203 .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;}
21204 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21205 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21206 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21207 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21208 .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;}
21209 .tz-select:focus{border-color:var(--oxide);}
21210 .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;}
21211 .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;}
21212 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21213 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21214 .hero{text-align:center;margin:0 auto 18px;}
21215 .hero-logo-wrap{display:inline-block;cursor:default;}
21216 .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;}
21217 .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;}
21218 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21219 .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;}
21220 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%);}
21221 .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;
21222 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21223 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21224 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;}
21225 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21226 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21227 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;}
21228 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21229 .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;}
21230 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21231 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21232 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21233 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21234 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21235 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21236 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21237 .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;}
21238 .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;}
21239 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21240 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21241 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21242 .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);}
21243 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21244 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21245 .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);}
21246 .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);}
21247 .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);}
21248 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21249 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21250 .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;}
21251 body.dark-theme .action-card-cta{color:var(--oxide);}
21252 .action-card.view .action-card-cta{color:var(--accent-2);}
21253 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21254 .action-card.compare .action-card-cta{color:#7c3aed;}
21255 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21256 .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);}
21257 .action-card.git-tools .action-card-cta{color:#15803d;}
21258 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21259 .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);}
21260 .action-card.trend .action-card-cta{color:#0e7490;}
21261 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21262 .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);}
21263 .action-card.automation .action-card-cta{color:#b45309;}
21264 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21265 .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);}
21266 .action-card.test-metrics .action-card-cta{color:#be185d;}
21267 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21268 .action-card:hover .action-card-cta{gap:12px;}
21269 .action-card.card-split{flex-direction:row;align-items:stretch;}
21270 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21271 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21272 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21273 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21274 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21275 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21276 .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;}
21277 .ac-badge.active{opacity:1;}
21278 .ac-badge.github{border-color:#555;color:#555;}
21279 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21280 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21281 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21282 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21283 body.dark-theme .ac-right-row{color:var(--muted);}
21284 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21285 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21286 .divider{height:1px;background:var(--line);margin:32px 0;}
21287 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21288 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21289 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21290 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21291 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21292 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21293 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21294 body.dark-theme .info-chip-val{color:var(--oxide);}
21295 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21296 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21297 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21298 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21299 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21300 border:6px solid transparent;border-top-color:var(--text);}
21301 .info-chip:hover .info-chip-tip{display:block;}
21302 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21303 .chip-slide.fading{filter:blur(5px);opacity:0;}
21304 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21305 .site-footer a{color:var(--muted);}
21306 .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;}
21307 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21308 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21309 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21310 .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;}
21311 .lan-badge.local{background:var(--oxide-2);}
21312 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21313 .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);}
21314 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21315 .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;}
21316 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21317 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21318 .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;}
21319 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21320 .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;}
21321 .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);}
21322 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21323 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21324 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21325 .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;}
21326 @media (max-height: 1100px) {
21327 .page{padding-top:10px;}
21328 .hero{margin-bottom:10px;}
21329 .hero-logo{width:54px;height:60px;}
21330 .hero-logo-shadow{width:42px;}
21331 .hero-title{font-size:28px;}
21332 .hero-subtitle{font-size:13px;}
21333 .card-sections{gap:12px;margin-bottom:6px;}
21334 .card-section-grid-2,.card-section-grid-3{gap:10px;}
21335 .action-card{padding:8px 15px 8px;}
21336 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21337 .action-card-icon svg{width:18px;height:18px;}
21338 .action-card-title{font-size:13px;}
21339 .action-card-desc{font-size:11px;margin-bottom:6px;}
21340 .action-card-cta{font-size:11px;}
21341 .ac-right-row{font-size:11px;}
21342 .divider{margin:14px 0;}
21343 .info-strip{gap:7px;margin-bottom:8px;}
21344 .info-chip{padding:7px 10px;}
21345 .info-chip-val{font-size:13px;}
21346 .info-chip-label{font-size:9px;}
21347 .site-footer{padding:8px 24px;font-size:12px;}
21348 .lan-local-hint{margin-top:8px;}
21349 }
21350 @media (max-height: 850px) {
21351 .page{padding-top:6px;}
21352 .hero{margin-bottom:6px;}
21353 .hero-logo{width:42px;height:46px;}
21354 .hero-title{font-size:22px;}
21355 .hero-subtitle{font-size:12px;}
21356 .card-sections{gap:10px;}
21357 .action-card-desc{margin-bottom:4px;}
21358 .divider{margin:8px 0;}
21359 .info-strip{margin-bottom:6px;}
21360 .lan-local-hint{margin-top:10px;}
21361 }
21362 </style>
21363</head>
21364<body>
21365 <div class="background-watermarks" aria-hidden="true">
21366 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21367 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21368 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21369 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21370 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21371 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21372 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21373 </div>
21374 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21375 <div class="top-nav">
21376 <div class="top-nav-inner">
21377 <a class="brand" href="/">
21378 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21379 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21380 </a>
21381 <div class="nav-right">
21382 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21383 <div class="nav-dropdown">
21384 <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>
21385 <div class="nav-dropdown-menu">
21386 <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>
21387 </div>
21388 </div>
21389 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21390 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21391 <div class="nav-dropdown">
21392 <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>
21393 <div class="nav-dropdown-menu">
21394 <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>
21395 </div>
21396 </div>
21397 <div class="server-status-wrap" id="server-status-wrap">
21398 <div class="nav-pill server-online-pill" id="server-status-pill">
21399 <span class="status-dot" id="status-dot"></span>
21400 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21401 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21402 </div>
21403 <div class="server-status-tip">
21404 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21405 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21406 </div>
21407 </div>
21408 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21409 <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>
21410 </button>
21411 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21412 <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>
21413 <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>
21414 </button>
21415 </div>
21416 </div>
21417 </div>
21418
21419 <div class="page">
21420 <div class="hero">
21421 <div class="hero-logo-wrap" id="hero-logo-wrap">
21422 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21423 </div>
21424 <div class="hero-logo-shadow"></div>
21425 <div class="hero-title-wrap">
21426 <div class="hero-title-aura" aria-hidden="true"></div>
21427 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21428 </div>
21429 <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>
21430 </div>
21431
21432 <div class="card-sections">
21433
21434 <div>
21435 <div class="card-section-label">Analysis</div>
21436 <div class="card-section-grid-2">
21437 <a class="action-card scan card-split" href="/scan-setup">
21438 <div class="action-card-left">
21439 <div class="action-card-icon">
21440 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21441 </div>
21442 <div class="action-card-title">Scan Project</div>
21443 <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>
21444 <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>
21445 </div>
21446 <div class="action-card-sep"></div>
21447 <div class="action-card-right">
21448 <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>
21449 <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>
21450 <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>
21451 <div class="ac-right-stat" id="acp-scan-stat"></div>
21452 </div>
21453 </a>
21454 <a class="action-card test-metrics card-split" href="/test-metrics">
21455 <div class="action-card-left">
21456 <div class="action-card-icon">
21457 <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>
21458 </div>
21459 <div class="action-card-title">Test Metrics</div>
21460 <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>
21461 <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>
21462 </div>
21463 <div class="action-card-sep"></div>
21464 <div class="action-card-right">
21465 <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>
21466 <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>
21467 <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>
21468 <div class="ac-right-stat" id="acp-test-stat"></div>
21469 </div>
21470 </a>
21471 </div>
21472 </div>
21473
21474 <div>
21475 <div class="card-section-label">Reports & Insights</div>
21476 <div class="card-section-grid-3">
21477 <a class="action-card view" href="/view-reports">
21478 <div class="action-card-icon">
21479 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21480 </div>
21481 <div class="action-card-title">View Reports</div>
21482 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21483 <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>
21484 </a>
21485 <a class="action-card compare" href="/compare-scans">
21486 <div class="action-card-icon">
21487 <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>
21488 </div>
21489 <div class="action-card-title">Compare Scans</div>
21490 <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>
21491 <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>
21492 </a>
21493 <a class="action-card trend" href="/trend-reports">
21494 <div class="action-card-icon">
21495 <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>
21496 </div>
21497 <div class="action-card-title">Trend Report</div>
21498 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21499 <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>
21500 </a>
21501 </div>
21502 </div>
21503
21504 <div>
21505 <div class="card-section-label">Developer Tools</div>
21506 <div class="card-section-grid-2">
21507 <a class="action-card git-tools card-split" href="/git-browser">
21508 <div class="action-card-left">
21509 <div class="action-card-icon">
21510 <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>
21511 </div>
21512 <div class="action-card-title">Git Browser</div>
21513 <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>
21514 <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>
21515 </div>
21516 <div class="action-card-sep"></div>
21517 <div class="action-card-right">
21518 <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>
21519 <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>
21520 <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>
21521 </div>
21522 </a>
21523 <a class="action-card automation card-split" href="/integrations">
21524 <div class="action-card-left">
21525 <div class="action-card-icon">
21526 <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>
21527 </div>
21528 <div class="action-card-title">Integrations</div>
21529 <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>
21530 <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>
21531 </div>
21532 <div class="action-card-sep"></div>
21533 <div class="action-card-right">
21534 <div class="ac-badges-grid">
21535 <span class="ac-badge github" id="acp-gh">GitHub</span>
21536 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
21537 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
21538 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21539 </div>
21540 <div class="ac-right-stat" id="acp-int-stat"></div>
21541 </div>
21542 </a>
21543 </div>
21544 </div>
21545
21546 </div>
21547
21548 {% if server_mode %}
21549 <div class="lan-card server">
21550 <div class="lan-card-header">
21551 <span class="lan-badge">LAN server</span>
21552 Accessible on your network
21553 </div>
21554 {% if let Some(ip) = lan_ip %}
21555 <div class="lan-url-row">
21556 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21557 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21558 <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>
21559 Copy URL
21560 </button>
21561 </div>
21562 <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>
21563 {% if has_api_key %}
21564 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
21565 {% endif %}
21566 {% else %}
21567 <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>
21568 {% endif %}
21569 </div>
21570 {% endif %}
21571
21572 <div class="divider"></div>
21573
21574 <div class="info-strip">
21575 <div class="info-chip">
21576 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21577 <div class="chip-slide">
21578 <div class="info-chip-val">60</div>
21579 <div class="info-chip-label">Languages</div>
21580 </div>
21581 </div>
21582 <div class="info-chip">
21583 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21584 <div class="chip-slide">
21585 <div class="info-chip-val">100%</div>
21586 <div class="info-chip-label">Self-contained</div>
21587 </div>
21588 </div>
21589 <div class="info-chip">
21590 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21591 <div class="chip-slide">
21592 <div class="info-chip-val">HTML+PDF</div>
21593 <div class="info-chip-label">Exportable reports</div>
21594 </div>
21595 </div>
21596 <div class="info-chip">
21597 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21598 <div class="chip-slide">
21599 <div class="info-chip-val">Webhook</div>
21600 <div class="info-chip-label">3 platforms</div>
21601 </div>
21602 </div>
21603 <div class="info-chip">
21604 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21605 <div class="chip-slide">
21606 <div class="info-chip-val">IEEE</div>
21607 <div class="info-chip-label">1045-1992</div>
21608 </div>
21609 </div>
21610 </div>
21611
21612 {% if lan_ip.is_none() %}
21613 <div class="lan-local-hint">
21614 <strong>Want teammates on the same network to access this?</strong><br>
21615 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
21616 </div>
21617 {% endif %}
21618 </div>
21619
21620 <footer class="site-footer">
21621 local code analysis - metrics, history and reports
21622 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21623 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21624 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21625 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21626 · <a href="/api-docs" rel="noopener">REST API</a>
21627 </footer>
21628
21629 <script nonce="{{ csp_nonce }}">
21630 (function () {
21631 var storageKey = 'oxide-sloc-theme';
21632 var body = document.body;
21633 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21634 var toggle = document.getElementById('theme-toggle');
21635 if (toggle) toggle.addEventListener('click', function () {
21636 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21637 body.classList.toggle('dark-theme', next === 'dark');
21638 try { localStorage.setItem(storageKey, next); } catch(e) {}
21639 });
21640 var copyBtn = document.getElementById('lan-copy-btn');
21641 if (copyBtn) copyBtn.addEventListener('click', function() {
21642 var btn = this;
21643 var el = document.getElementById('lan-url-val');
21644 if (!el) return;
21645 var url = el.textContent.trim();
21646 if (navigator.clipboard) {
21647 navigator.clipboard.writeText(url).then(function() {
21648 var orig = btn.innerHTML;
21649 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!';
21650 setTimeout(function() { btn.innerHTML = orig; }, 1800);
21651 });
21652 }
21653 });
21654 (function randomizeWatermarks() {
21655 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21656 if (!wms.length) return;
21657 var placed = [];
21658 function tooClose(top, left) {
21659 for (var i = 0; i < placed.length; i++) {
21660 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21661 if (dt < 16 && dl < 12) return true;
21662 }
21663 return false;
21664 }
21665 function pick(leftBand) {
21666 for (var attempt = 0; attempt < 50; attempt++) {
21667 var top = Math.random() * 88 + 2;
21668 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21669 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21670 }
21671 var top = Math.random() * 88 + 2;
21672 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21673 placed.push([top, left]); return [top, left];
21674 }
21675 var half = Math.floor(wms.length / 2);
21676 wms.forEach(function (img, i) {
21677 var pos = pick(i < half);
21678 var size = Math.floor(Math.random() * 100 + 120);
21679 var rot = (Math.random() * 360).toFixed(1);
21680 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21681 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;
21682 });
21683 })();
21684
21685 (function spawnCodeParticles() {
21686 var container = document.getElementById('code-particles');
21687 if (!container) return;
21688 var snippets = [
21689 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21690 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21691 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21692 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21693 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21694 ];
21695 var count = 38;
21696 for (var i = 0; i < count; i++) {
21697 (function(idx) {
21698 var el = document.createElement('span');
21699 el.className = 'code-particle';
21700 var text = snippets[idx % snippets.length];
21701 el.textContent = text;
21702 var left = Math.random() * 94 + 2;
21703 var top = Math.random() * 88 + 6;
21704 var dur = (Math.random() * 10 + 9).toFixed(1);
21705 var delay = (Math.random() * 18).toFixed(1);
21706 var rot = (Math.random() * 26 - 13).toFixed(1);
21707 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21708 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21709 + '--rot:' + rot + 'deg;--op:' + op + ';'
21710 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21711 container.appendChild(el);
21712 })(i);
21713 }
21714 })();
21715 (function heroAnimations() {
21716 var sub = document.getElementById('hero-subtitle');
21717 if (sub) {
21718 var full = sub.textContent.trim();
21719 sub.textContent = '';
21720 sub.style.opacity = '1';
21721 var cursor = document.createElement('span');
21722 cursor.className = 'hero-cursor';
21723 sub.appendChild(cursor);
21724 var i = 0;
21725 setTimeout(function() {
21726 var iv = setInterval(function() {
21727 if (i < full.length) {
21728 sub.insertBefore(document.createTextNode(full[i]), cursor);
21729 i++;
21730 } else {
21731 clearInterval(iv);
21732 setTimeout(function() {
21733 cursor.style.transition = 'opacity 1s ease';
21734 cursor.style.opacity = '0';
21735 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21736 }, 2400);
21737 }
21738 }, 11);
21739 }, 374);
21740 }
21741 })();
21742 (function logoBob() {
21743 var logo = document.querySelector('.hero-logo');
21744 var shadow = document.querySelector('.hero-logo-shadow');
21745 if (!logo) return;
21746 var cycleStart = null, cycleDur = 3600;
21747 var peakY = -14, peakScale = 1.07, peakRot = 0;
21748 function newCycle() {
21749 cycleDur = 3000 + Math.random() * 1840;
21750 peakY = -(9 + Math.random() * 13.8);
21751 peakScale = 1.04 + Math.random() * 0.081;
21752 peakRot = (Math.random() * 11.5 - 5.75);
21753 }
21754 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21755 newCycle();
21756 function frame(ts) {
21757 if (cycleStart === null) cycleStart = ts;
21758 var t = (ts - cycleStart) / cycleDur;
21759 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21760 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21761 var y = peakY * phase;
21762 var sc = 1 + (peakScale - 1) * phase;
21763 var rot = peakRot * Math.sin(Math.PI * phase);
21764 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21765 if (shadow) {
21766 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21767 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21768 }
21769 requestAnimationFrame(frame);
21770 }
21771 requestAnimationFrame(frame);
21772 })();
21773 (function mouseEffects() {
21774 var heroTitle = document.getElementById('hero-title');
21775 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21776 function tick() {
21777 raf = null;
21778 if (heroTitle) {
21779 var r = heroTitle.getBoundingClientRect();
21780 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21781 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21782 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21783 }
21784 }
21785 document.addEventListener('mousemove', function(e) {
21786 mx = e.clientX; my = e.clientY;
21787 if (!raf) raf = requestAnimationFrame(tick);
21788 });
21789 document.addEventListener('mouseleave', function() {
21790 if (heroTitle) {
21791 heroTitle.style.transition = 'transform 0.5s ease';
21792 heroTitle.style.transform = '';
21793 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21794 }
21795 });
21796 document.querySelectorAll('.action-card').forEach(function(card) {
21797 card.addEventListener('mousemove', function(e) {
21798 var rect = card.getBoundingClientRect();
21799 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21800 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21801 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21802 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21803 });
21804 card.addEventListener('mouseleave', function() {
21805 card.style.transition = '';
21806 card.style.transform = '';
21807 });
21808 });
21809 })();
21810 (function chipSlideshow() {
21811 var slides = [
21812 [{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'}],
21813 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21814 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21815 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21816 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21817 ];
21818 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21819 var indices = [0,0,0,0,0];
21820 var paused = [false,false,false,false,false];
21821 chips.forEach(function(chip, i) {
21822 chip.addEventListener('mouseenter', function() { paused[i] = true; });
21823 chip.addEventListener('mouseleave', function() { paused[i] = false; });
21824 });
21825 function advance(i) {
21826 if (paused[i]) return;
21827 var chip = chips[i];
21828 var inner = chip.querySelector('.chip-slide');
21829 if (!inner) return;
21830 inner.classList.add('fading');
21831 setTimeout(function() {
21832 indices[i] = (indices[i] + 1) % slides[i].length;
21833 var s = slides[i][indices[i]];
21834 chip.querySelector('.info-chip-val').textContent = s.v;
21835 chip.querySelector('.info-chip-label').textContent = s.l;
21836 inner.classList.remove('fading');
21837 }, 720);
21838 }
21839 setInterval(function() {
21840 chips.forEach(function(chip, i) { advance(i); });
21841 }, 6000);
21842 })();
21843 (function cardLiveData() {
21844 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21845 var el = document.getElementById('acp-scan-stat');
21846 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21847 }).catch(function(){});
21848 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21849 var el = document.getElementById('acp-test-stat');
21850 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21851 }).catch(function(){});
21852 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21853 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21854 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21855 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21856 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21857 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21858 var stat = document.getElementById('acp-int-stat');
21859 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21860 }).catch(function(){});
21861 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21862 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21863 }).catch(function(){});
21864 })();
21865 })();
21866 </script>
21867 <script nonce="{{ csp_nonce }}">
21868 (function(){
21869 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'}];
21870 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);});}
21871 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21872 function init(){
21873 var btn=document.getElementById('settings-btn');if(!btn)return;
21874 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21875 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>';
21876 document.body.appendChild(m);
21877 var g=document.getElementById('scheme-grid');
21878 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);});
21879 var cl=document.getElementById('settings-close');
21880 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);
21881 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');});
21882 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21883 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21884 }
21885 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21886 }());
21887 </script>
21888 <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>
21889</body>
21890</html>
21891"##,
21892 ext = "html"
21893)]
21894struct SplashTemplate {
21895 csp_nonce: String,
21896 server_mode: bool,
21897 lan_ip: Option<String>,
21898 port: u16,
21899 version: &'static str,
21900 has_api_key: bool,
21901}
21902
21903#[derive(Template)]
21906#[template(
21907 source = r##"
21908<!doctype html>
21909<html lang="en">
21910<head>
21911 <meta charset="utf-8">
21912 <meta name="viewport" content="width=device-width, initial-scale=1">
21913 <title>OxideSLOC — Start a Scan</title>
21914 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21915 <style nonce="{{ csp_nonce }}">
21916 :root {
21917 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
21918 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21919 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21920 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21921 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21922 }
21923 body.dark-theme {
21924 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21925 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21926 }
21927 *{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;}
21928 .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);}
21929 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21930 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
21931 .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));}
21932 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21933 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21934 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
21935 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21936 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21937 @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; } }
21938 .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;}
21939 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21940 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21941 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21942 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21943 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21944 .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;}
21945 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21946 .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);}
21947 .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;}
21948 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21949 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21950 .settings-modal-body{padding:14px 16px 16px;}
21951 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21952 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21953 .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;}
21954 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21955 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21956 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21957 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21958 .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;}
21959 .tz-select:focus{border-color:var(--oxide);}
21960 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21961 .page-header{text-align:center;margin-bottom:16px;}
21962 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
21963 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
21964 /* Cards */
21965 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
21966 .option-card-wrap{position:relative;}
21967 .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;}
21968 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
21969 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21970 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
21971 .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;}
21972 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21973 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
21974 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
21975 .card-top-row{display:flex;align-items:center;gap:20px;}
21976 /* Two-column layout inside each card */
21977 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
21978 .card-left{display:flex;align-items:flex-start;min-width:0;}
21979 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
21980 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
21981 .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);}
21982 .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);}
21983 .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);}
21984 .card-text{min-width:0;}
21985 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
21986 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
21987 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
21988 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
21989 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
21990 /* Right CTA column */
21991 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
21992 .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;}
21993 /* Re-scan count badge */
21994 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
21995 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
21996 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
21997 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
21998 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
21999 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22000 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22001 body.dark-theme .btn-secondary{color:var(--oxide);}
22002 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22003 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22004 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22005 .file-input-wrap{position:relative;width:100%;}
22006 .file-input-wrap .btn{width:100%;}
22007 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22008 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22009 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22010 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22011 .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;}
22012 @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));}}
22013 /* Recent list (card 3 — full-width section below header) */
22014 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22015 .recent-list{display:flex;flex-direction:column;gap:8px;}
22016 .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;}
22017 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22018 .recent-item-info{flex:1;min-width:0;}
22019 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22020 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22021 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22022 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22023 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22024 .site-footer a{color:var(--muted);}
22025 @media(max-width:680px){
22026 .card-body{grid-template-columns:1fr;}
22027 .card-right{flex-direction:row;flex-wrap:wrap;}
22028 .btn{flex:1;}
22029 }
22030 .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;}
22031 .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;}
22032 .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;}
22033 </style>
22034</head>
22035<body>
22036 <div class="background-watermarks" aria-hidden="true">
22037 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22038 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22039 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22040 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22041 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22044 </div>
22045 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22046 <div class="top-nav">
22047 <div class="top-nav-inner">
22048 <a class="brand" href="/">
22049 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22050 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22051 </a>
22052 <div class="nav-right">
22053 <a class="nav-pill" href="/">Home</a>
22054 <div class="nav-dropdown">
22055 <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>
22056 <div class="nav-dropdown-menu">
22057 <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>
22058 </div>
22059 </div>
22060 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22061 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22062 <div class="nav-dropdown">
22063 <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>
22064 <div class="nav-dropdown-menu">
22065 <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>
22066 </div>
22067 </div>
22068 <div class="server-status-wrap" id="server-status-wrap">
22069 <div class="nav-pill server-online-pill" id="server-status-pill">
22070 <span class="status-dot" id="status-dot"></span>
22071 <span id="server-status-label">Server</span>
22072 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22073 </div>
22074 <div class="server-status-tip">
22075 OxideSLOC is running — accessible on your network.
22076 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22077 </div>
22078 </div>
22079 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22080 <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>
22081 </button>
22082 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22083 <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>
22084 <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>
22085 </button>
22086 </div>
22087 </div>
22088 </div>
22089
22090 <div class="page">
22091 <div class="page-header">
22092 <h1>How would you like to scan?</h1>
22093 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22094 </div>
22095
22096 <div class="option-grid">
22097
22098 <!-- Option 1: New scan -->
22099 <div class="option-card-wrap">
22100 <div class="option-card">
22101 <div class="option-icon new-scan">
22102 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22103 </div>
22104 <div class="card-body">
22105 <div class="card-left">
22106 <div class="card-text">
22107 <div class="option-title">Start a new scan</div>
22108 <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>
22109 <ul class="feature-list">
22110 <li>Live project scope preview before you run</li>
22111 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22112 <li>HTML, PDF, and JSON output — your choice</li>
22113 </ul>
22114 </div>
22115 </div>
22116 <div class="card-right">
22117 <a class="btn btn-primary" href="/scan">
22118 Configure & scan
22119 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22120 </a>
22121 <p class="card-tip">Full 4-step setup · all options</p>
22122 </div>
22123 </div>
22124 </div>
22125 </div>
22126
22127 <!-- Option 2: Load from config file -->
22128 <div class="option-card-wrap">
22129 <div class="option-card">
22130 <div class="option-icon load-config">
22131 <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>
22132 </div>
22133 <div class="card-body">
22134 <div class="card-left">
22135 <div class="card-text">
22136 <div class="option-title">Load a saved config</div>
22137 <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>
22138 <ul class="feature-list">
22139 <li>All 15 settings restored from the file</li>
22140 <li>Fully editable — change path or output dir</li>
22141 <li>Works with any scan-config.json</li>
22142 </ul>
22143 </div>
22144 </div>
22145 <div class="card-right">
22146 <div class="file-input-wrap">
22147 <button class="btn btn-secondary" id="load-config-btn" type="button">
22148 <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>
22149 Choose config file
22150 </button>
22151 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22152 </div>
22153 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22154 </div>
22155 </div>
22156 </div>
22157 </div>
22158
22159 <!-- Option 3: Re-scan recent project -->
22160 <div class="option-card-wrap">
22161 <div class="option-card" id="recent-card">
22162 <div class="card-top-row">
22163 <div class="option-icon rescan">
22164 <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>
22165 </div>
22166 <div class="card-body">
22167 <div class="card-left">
22168 <div class="card-text">
22169 <div class="option-title">Re-scan a recent project</div>
22170 <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>
22171 <ul class="feature-list">
22172 <li>All 15+ settings restored from the saved config</li>
22173 <li>Path and output dir are editable before running</li>
22174 <li>Only scans with a saved config appear here</li>
22175 </ul>
22176 </div>
22177 </div>
22178 <div class="card-right">
22179 <div class="rescan-count-box">
22180 <div class="rescan-count-num" id="rescan-count-num">—</div>
22181 <div class="rescan-count-label">saved configs</div>
22182 </div>
22183 <a class="btn btn-secondary" href="/view-reports">
22184 <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>
22185 View all runs
22186 </a>
22187 <p class="card-tip">Opens run history</p>
22188 </div>
22189 </div>
22190 </div>
22191 <div class="section-divider"></div>
22192 <div class="recent-list" id="recent-list">
22193 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22194 </div>
22195 </div>
22196 </div>
22197
22198 </div>
22199 </div>
22200
22201 <footer class="site-footer">
22202 local code analysis - metrics, history and reports
22203 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22204 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22205 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22206 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22207 · <a href="/api-docs" rel="noopener">REST API</a>
22208 </footer>
22209
22210 <script nonce="{{ csp_nonce }}">
22211 (function () {
22212 var storageKey = 'oxide-sloc-theme';
22213 var body = document.body;
22214 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22215 var toggle = document.getElementById('theme-toggle');
22216 if (toggle) toggle.addEventListener('click', function () {
22217 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22218 body.classList.toggle('dark-theme', next === 'dark');
22219 try { localStorage.setItem(storageKey, next); } catch(e) {}
22220 });
22221
22222 (function randomizeWatermarks() {
22223 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22224 if (!wms.length) return;
22225 var placed = [];
22226 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; }
22227 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]; }
22228 var half = Math.floor(wms.length / 2);
22229 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; });
22230 })();
22231 (function spawnCodeParticles() {
22232 var container = document.getElementById('code-particles');
22233 if (!container) return;
22234 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'];
22235 var count = 38;
22236 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); }
22237 })();
22238 // Recent scans data injected from server
22239 var recentScans = {{ recent_scans_json|safe }};
22240
22241 function configToParams(cfg) {
22242 var p = new URLSearchParams();
22243 p.set('prefilled', '1');
22244 if (cfg.path) p.set('path', cfg.path);
22245 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22246 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22247 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22248 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22249 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22250 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22251 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22252 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22253 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22254 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22255 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22256 if (cfg.report_title) p.set('report_title', cfg.report_title);
22257 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22258 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22259 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22260 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22261 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22262 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22263 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22264 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22265 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22266 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22267 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22268 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22269 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22270 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22271 return p;
22272 }
22273
22274 // Build recent scan list (capped at 3 visible entries)
22275 var list = document.getElementById('recent-list');
22276 var noNote = document.getElementById('no-recent-note');
22277 var hasAny = false;
22278 var MAX_RECENT = 3;
22279 if (Array.isArray(recentScans)) {
22280 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22281 var shown = 0;
22282 validEntries.forEach(function (entry) {
22283 if (shown >= MAX_RECENT) return;
22284 shown++;
22285 hasAny = true;
22286 var item = document.createElement('div');
22287 item.className = 'recent-item';
22288 item.title = 'Restore all settings and open wizard';
22289 item.innerHTML =
22290 '<div class="recent-item-info">' +
22291 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22292 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
22293 '</div>' +
22294 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22295 item.addEventListener('click', function () {
22296 var params = configToParams(entry.config);
22297 window.location.href = '/scan?' + params.toString();
22298 });
22299 list.appendChild(item);
22300 });
22301 if (validEntries.length > MAX_RECENT) {
22302 var moreEl = document.createElement('div');
22303 moreEl.className = 'recent-more-link';
22304 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
22305 list.appendChild(moreEl);
22306 }
22307 }
22308 if (hasAny && noNote) noNote.style.display = 'none';
22309 // Update count badge
22310 var countEl = document.getElementById('rescan-count-num');
22311 if (countEl) {
22312 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22313 countEl.textContent = total > 0 ? total : '0';
22314 }
22315
22316 // Config file loader
22317 var fileInput = document.getElementById('config-file-input');
22318 var fileName = document.getElementById('config-file-name');
22319 var loadBtn = document.getElementById('load-config-btn');
22320 // Wire the visible button to open the hidden file picker.
22321 if (loadBtn && fileInput) {
22322 loadBtn.addEventListener('click', function () { fileInput.click(); });
22323 }
22324 if (fileInput) {
22325 fileInput.addEventListener('change', function () {
22326 var file = fileInput.files && fileInput.files[0];
22327 if (!file) return;
22328 if (fileName) fileName.textContent = '\u2713 ' + file.name;
22329 var reader = new FileReader();
22330 reader.onload = function (e) {
22331 try {
22332 var cfg = JSON.parse(e.target.result);
22333 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22334 var params = configToParams(cfg);
22335 window.location.href = '/scan?' + params.toString();
22336 } catch (err) {
22337 alert('Could not parse config file: ' + err.message);
22338 }
22339 };
22340 reader.readAsText(file);
22341 });
22342 }
22343
22344 function escHtml(s) {
22345 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
22346 }
22347 })();
22348 </script>
22349 <script nonce="{{ csp_nonce }}">
22350 (function(){
22351 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'}];
22352 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);});}
22353 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22354 function init(){
22355 var btn=document.getElementById('settings-btn');if(!btn)return;
22356 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22357 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>';
22358 document.body.appendChild(m);
22359 var g=document.getElementById('scheme-grid');
22360 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);});
22361 var cl=document.getElementById('settings-close');
22362 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);
22363 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');});
22364 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22365 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22366 }
22367 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22368 }());
22369 </script>
22370 <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]';
22371 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;}
22372 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>
22373</body>
22374</html>
22375"##,
22376 ext = "html"
22377)]
22378struct ScanSetupTemplate {
22379 version: &'static str,
22380 recent_scans_json: String,
22381 csp_nonce: String,
22382}
22383
22384#[derive(Template)]
22385#[template(
22386 source = r##"
22387<!doctype html>
22388<html lang="en">
22389<head>
22390 <meta charset="utf-8">
22391 <meta name="viewport" content="width=device-width, initial-scale=1">
22392 <title>OxideSLOC | {{ report_title }} | Report</title>
22393 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22394 <style nonce="{{ csp_nonce }}">
22395 :root {
22396 --radius: 18px;
22397 --bg: #f5efe8;
22398 --surface: rgba(255,255,255,0.82);
22399 --surface-2: #fbf7f2;
22400 --surface-3: #efe6dc;
22401 --line: #e6d0bf;
22402 --line-strong: #dcb89f;
22403 --text: #43342d;
22404 --muted: #7b675b;
22405 --muted-2: #a08777;
22406 --nav: #b85d33;
22407 --nav-2: #7a371b;
22408 --accent: #6f9bff;
22409 --accent-2: #4a78ee;
22410 --oxide: #d37a4c;
22411 --oxide-2: #b35428;
22412 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22413 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22414 --success-bg: #e8f5ed;
22415 --success-text: #1a8f47;
22416 --info-bg: #eef3ff;
22417 --info-text: #4467d8;
22418 }
22419
22420 body.dark-theme {
22421 --bg: #1b1511;
22422 --surface: #261c17;
22423 --surface-2: #2d221d;
22424 --surface-3: #372922;
22425 --line: #524238;
22426 --line-strong: #6c5649;
22427 --text: #f5ece6;
22428 --muted: #c7b7aa;
22429 --muted-2: #aa9485;
22430 --nav: #b85d33;
22431 --nav-2: #7a371b;
22432 --accent: #6f9bff;
22433 --accent-2: #4a78ee;
22434 --oxide: #d37a4c;
22435 --oxide-2: #b35428;
22436 --shadow: 0 18px 42px rgba(0,0,0,0.28);
22437 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22438 --success-bg: #163927;
22439 --success-text: #8fe2a8;
22440 --info-bg: #1c2847;
22441 --info-text: #a9c1ff;
22442 }
22443
22444 * { box-sizing: border-box; }
22445 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); }
22446 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22447 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22448 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22449 .top-nav, .page { position: relative; z-index: 2; }
22450 .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); }
22451 .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; }
22452 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22453 .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)); }
22454 .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; }
22455 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22456 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22457 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22458 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22459 .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; }
22460 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22461 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22462 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22463 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22464 @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; } }
22465 .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; }
22466 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22467 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22468 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22469 .theme-toggle .icon-sun { display:none; }
22470 body.dark-theme .theme-toggle .icon-sun { display:block; }
22471 body.dark-theme .theme-toggle .icon-moon { display:none; }
22472 .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;}
22473 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22474 .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);}
22475 .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;}
22476 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22477 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22478 .settings-modal-body{padding:14px 16px 16px;}
22479 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22480 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22481 .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;}
22482 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22483 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22484 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22485 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22486 .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;}
22487 .tz-select:focus{border-color:var(--oxide);}
22488 .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; }
22489 .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;}
22490 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22491 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22492 .hero, .panel { padding: 22px; }
22493 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22494 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22495 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22496 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22497 .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; }
22498 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22499 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22500 .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; }
22501 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22502 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22503 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22504 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22505 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22506 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22507 .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); }
22508 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22509 .delta-card-val { font-size:16px; font-weight:800; }
22510 .delta-card-val.pos { color:#1e7e34; }
22511 .delta-card-val.neg { color:var(--neg); }
22512 .delta-card-val.mod { color:#b35428; }
22513 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22514 .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; }
22515 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22516 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22517 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22518 .compare-ts { font-size:13px; color:var(--muted); }
22519 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22520 .compare-arrow { color: var(--muted); }
22521 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22522 .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; }
22523 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22524 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22525 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22526 .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; }
22527 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22528 .run-mgmt-card .action-buttons { justify-content:center; }
22529 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22530 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22531 .button, .copy-button {
22532 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;
22533 }
22534 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22535 @keyframes spin { to { transform: rotate(360deg); } }
22536 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22537 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22538 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22539 .path-item strong { display: block; margin-bottom: 6px; }
22540 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22541 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22542 .path-subitem { flex: 1; }
22543 .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); }
22544 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); }
22545 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22546 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22547 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22548 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22549 th { color: var(--muted); font-weight: 700; }
22550 tr:last-child td { border-bottom: none; }
22551 #subm-tbl col:nth-child(1){width:15%;}
22552 #subm-tbl col:nth-child(2){width:31%;}
22553 #subm-tbl col:nth-child(3){width:9%;}
22554 #subm-tbl col:nth-child(4){width:9%;}
22555 #subm-tbl col:nth-child(5){width:9%;}
22556 #subm-tbl col:nth-child(6){width:9%;}
22557 #subm-tbl col:nth-child(7){width:9%;}
22558 #subm-tbl col:nth-child(8){width:9%;}
22559 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22560 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22561 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22562 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22563 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22564 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22565 .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; }
22566 .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; }
22567 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22568 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22569 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22570 .muted { color: var(--muted); }
22571 /* Run-ID chip row (mirrors HTML report) */
22572 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22573 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22574 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22575 .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; }
22576 .run-id-chip[data-copy] { cursor:pointer; }
22577 a.run-id-chip { text-decoration:none; cursor:pointer; }
22578 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22579 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22580 .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; }
22581 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22582 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22583 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22584 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22585 a.commit-link-value { color:inherit; text-decoration:none; }
22586 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22587 .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; }
22588 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22589 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22590 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22591 .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; }
22592 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22593 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22594 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22595 /* Meta chips row */
22596 .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%; }
22597 .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; }
22598 .meta-chip:last-child { border-right:none; }
22599 .meta-chip b { color:var(--text); font-weight:700; }
22600 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22601 .site-footer a{color:var(--muted);}
22602 .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; }
22603 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22604 .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; }
22605 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22606 /* Stat chips (matches HTML report) */
22607 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22608 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22609 /* Hero stat strip: uniform grid where every card is the same width and the
22610 columns line up across both rows. JS sets the column count to ceil(n/2) so
22611 the cards always occupy exactly two rows; when the count is odd the last
22612 card spans two columns to fill the trailing cell with no empty gap. */
22613 .summary-strip-hero { align-items:stretch; }
22614 .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; }
22615 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22616 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22617 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22618 .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; }
22619 .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); }
22620 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22621 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22622 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22623 .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; }
22624 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22625 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22626 .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); }
22627 .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); }
22628 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22629 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22630 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22631 /* Submodule panel */
22632 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22633 /* Metrics tables stack */
22634 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22635 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22636 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22637 .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)); }
22638 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22639 /* Metrics table */
22640 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22641 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22642 .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; }
22643 .metrics-table thead th:not(:first-child) { text-align: right; }
22644 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22645 .metrics-table tbody tr:last-child td { border-bottom: none; }
22646 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22647 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22648 .metrics-table tbody tr:hover td { background: var(--surface-2); }
22649 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22650 .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; }
22651 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22652 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22653 .mt-val-pos { color: var(--pos); font-weight: 700; }
22654 .mt-val-neg { color: var(--neg); font-weight: 700; }
22655 .mt-val-zero { color: var(--muted); }
22656 .mt-val-mod { color: var(--oxide-2); }
22657 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22658 @media (max-width: 1180px) {
22659 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22660 .nav-project-slot, .nav-status { justify-content:flex-start; }
22661 .hero-top { flex-direction: column; }
22662 .run-mgmt-strip { flex-direction: column; }
22663 }
22664 .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;}
22665 @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));}}
22666 .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;}
22667 /* ── Result-page chart controls ─────────────────────────────────────────── */
22668 .r-chart-section{margin-bottom:24px;}
22669 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22670 .section-pair > .panel{flex-shrink:0;}
22671 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22672 .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;}
22673 .r-chart-select:focus{border-color:var(--accent);}
22674 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22675 .r-chart-container svg{display:block;width:100%;height:auto;}
22676 .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;}
22677 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22678 .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;}
22679 .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);}
22680 .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;}
22681 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22682 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22683 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22684 .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;}
22685 .r-chart-modal-close:hover{opacity:.7;}
22686 body.dark-theme .r-chart-modal{background:var(--surface);}
22687 .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;}
22688 .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);}
22689 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22690 .lang-bar-row:hover{transform:translateY(-2px);}
22691 .lang-bar-row .rchit:hover{filter:none;transform:none;}
22692 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22693 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22694 .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;}
22695 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22696 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22697 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22698 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22699 #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;}
22700 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22701 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22702 .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;}
22703 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22704 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22705 .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;}
22706 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22707 .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;}
22708 .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;}
22709 body.has-report-banner .top-nav{top:27px;}
22710 body.has-report-banner{padding-bottom:27px;}
22711 </style>
22712</head>
22713<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22714 <div class="background-watermarks" aria-hidden="true">
22715 <img src="/images/logo/logo-text.png" alt="" />
22716 <img src="/images/logo/logo-text.png" alt="" />
22717 <img src="/images/logo/logo-text.png" alt="" />
22718 <img src="/images/logo/logo-text.png" alt="" />
22719 <img src="/images/logo/logo-text.png" alt="" />
22720 <img src="/images/logo/logo-text.png" alt="" />
22721 <img src="/images/logo/logo-text.png" alt="" />
22722 <img src="/images/logo/logo-text.png" alt="" />
22723 <img src="/images/logo/logo-text.png" alt="" />
22724 <img src="/images/logo/logo-text.png" alt="" />
22725 <img src="/images/logo/logo-text.png" alt="" />
22726 <img src="/images/logo/logo-text.png" alt="" />
22727 <img src="/images/logo/logo-text.png" alt="" />
22728 <img src="/images/logo/logo-text.png" alt="" />
22729 </div>
22730 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22731 {% if let Some(banner) = report_header_footer %}
22732 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22733 {% endif %}
22734 <div class="top-nav">
22735 <div class="top-nav-inner">
22736 <a class="brand" href="/">
22737 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22738 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22739 </a>
22740 <div class="nav-project-slot">
22741 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22742 </div>
22743 <div class="nav-status">
22744 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22745 <div class="nav-dropdown">
22746 <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>
22747 <div class="nav-dropdown-menu">
22748 <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>
22749 </div>
22750 </div>
22751 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22752 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22753 <div class="nav-dropdown">
22754 <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>
22755 <div class="nav-dropdown-menu">
22756 <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>
22757 </div>
22758 </div>
22759 <div class="server-status-wrap" id="server-status-wrap">
22760 <div class="nav-pill server-online-pill" id="server-status-pill">
22761 <span class="status-dot" id="status-dot"></span>
22762 <span id="server-status-label">Server</span>
22763 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22764 </div>
22765 <div class="server-status-tip">
22766 OxideSLOC is running — accessible on your network.
22767 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22768 </div>
22769 </div>
22770 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22771 <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>
22772 </button>
22773 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22774 <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>
22775 <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>
22776 </button>
22777 </div>
22778 </div>
22779 </div>
22780
22781 <div class="page">
22782 <section class="hero">
22783 <div class="hero-top">
22784 <div>
22785 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22786 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22787 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22788 <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>
22789 </div>
22790 </div>
22791 <div class="hero-quick-actions">
22792 {% if server_mode %}
22793 <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>
22794 {% else %}
22795 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22796 {% endif %}
22797 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22798 {% if !server_mode %}
22799 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22800 {% endif %}
22801 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22802 <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>
22803 </div>
22804 </div>
22805
22806 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22807 <div class="run-id-row">
22808 <span class="run-id-chip" data-copy="{{ run_id }}">
22809 <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>
22810 <span class="run-id-chip-value">{{ run_id }}</span>
22811 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22812 </span>
22813 {% match git_commit_long %}
22814 {% when Some with (long_sha) %}
22815 {% match git_commit_url %}
22816 {% when Some with (commit_url) %}
22817 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22818 <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>
22819 <span class="run-id-chip-value">{{ long_sha }}</span>
22820 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22821 </a>
22822 {% when None %}
22823 <span class="run-id-chip" data-copy="{{ long_sha }}">
22824 <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>
22825 <span class="run-id-chip-value">{{ long_sha }}</span>
22826 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22827 </span>
22828 {% endmatch %}
22829 {% when None %}
22830 <span class="run-id-chip muted-chip">
22831 <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>
22832 <span class="run-id-chip-value">Not detected</span>
22833 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22834 </span>
22835 {% endmatch %}
22836 {% match git_branch %}
22837 {% when Some with (branch) %}
22838 {% match git_branch_url %}
22839 {% when Some with (branch_url) %}
22840 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22841 <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>
22842 <span class="run-id-chip-value">{{ branch }}</span>
22843 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22844 </a>
22845 {% when None %}
22846 <span class="run-id-chip">
22847 <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>
22848 <span class="run-id-chip-value">{{ branch }}</span>
22849 <span class="chip-tooltip">Git branch active at scan time</span>
22850 </span>
22851 {% endmatch %}
22852 {% when None %}
22853 <span class="run-id-chip muted-chip">
22854 <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>
22855 <span class="run-id-chip-value">Not detected</span>
22856 <span class="chip-tooltip">No Git branch was found for this scan</span>
22857 </span>
22858 {% endmatch %}
22859 {% match git_author %}
22860 {% when Some with (author) %}
22861 <span class="run-id-chip" data-author="{{ author }}">
22862 <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>
22863 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22864 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22865 </span>
22866 {% when None %}
22867 <span class="run-id-chip muted-chip">
22868 <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>
22869 <span class="run-id-chip-value">Not detected</span>
22870 <span class="chip-tooltip">No commit author was found for this scan</span>
22871 </span>
22872 {% endmatch %}
22873 </div>
22874
22875 <!-- Scan metadata row -->
22876 <div class="meta">
22877 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22878 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22879 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22880 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22881 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22882 </div>
22883
22884 <!-- All summary stat chips in one unified strip (8 columns) -->
22885 <div class="summary-strip summary-strip-hero">
22886 <div class="stat-chip" data-raw="{{ physical_lines }}">
22887 <div class="stat-chip-label">Physical lines</div>
22888 <div class="stat-chip-val">{{ physical_lines }}</div>
22889 <div class="stat-chip-exact"></div>
22890 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
22891 </div>
22892 <div class="stat-chip" data-raw="{{ code_lines }}">
22893 <div class="stat-chip-label">Code</div>
22894 <div class="stat-chip-val">{{ code_lines }}</div>
22895 <div class="stat-chip-exact"></div>
22896 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
22897 </div>
22898 <div class="stat-chip" data-raw="{{ comment_lines }}">
22899 <div class="stat-chip-label">Comments</div>
22900 <div class="stat-chip-val">{{ comment_lines }}</div>
22901 <div class="stat-chip-exact"></div>
22902 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
22903 </div>
22904 <div class="stat-chip" data-raw="{{ blank_lines }}">
22905 <div class="stat-chip-label">Blank</div>
22906 <div class="stat-chip-val">{{ blank_lines }}</div>
22907 <div class="stat-chip-exact"></div>
22908 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
22909 </div>
22910 <div class="stat-chip" data-raw="{{ mixed_lines }}">
22911 <div class="stat-chip-label">Mixed separate</div>
22912 <div class="stat-chip-val">{{ mixed_lines }}</div>
22913 <div class="stat-chip-exact"></div>
22914 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
22915 </div>
22916 <div class="stat-chip" data-raw="{{ functions }}">
22917 <div class="stat-chip-label">Functions</div>
22918 <div class="stat-chip-val">{{ functions }}</div>
22919 <div class="stat-chip-exact"></div>
22920 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
22921 </div>
22922 <div class="stat-chip" data-raw="{{ classes }}">
22923 <div class="stat-chip-label">Classes / Types</div>
22924 <div class="stat-chip-val">{{ classes }}</div>
22925 <div class="stat-chip-exact"></div>
22926 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
22927 </div>
22928 <div class="stat-chip" data-raw="{{ variables }}">
22929 <div class="stat-chip-label">Variables</div>
22930 <div class="stat-chip-val">{{ variables }}</div>
22931 <div class="stat-chip-exact"></div>
22932 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
22933 </div>
22934 <div class="stat-chip" data-raw="{{ imports }}">
22935 <div class="stat-chip-label">Imports</div>
22936 <div class="stat-chip-val">{{ imports }}</div>
22937 <div class="stat-chip-exact"></div>
22938 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
22939 </div>
22940 <div class="stat-chip" data-raw="{{ test_count }}">
22941 <div class="stat-chip-label">Tests</div>
22942 <div class="stat-chip-val">{{ test_count }}</div>
22943 <div class="stat-chip-exact"></div>
22944 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
22945 </div>
22946 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
22947 <div class="stat-chip-label">Code density</div>
22948 <div class="stat-chip-val stat-chip-density-val">—</div>
22949 <div class="stat-chip-exact"></div>
22950 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
22951 </div>
22952 <div class="stat-chip" data-raw="{{ files_analyzed }}">
22953 <div class="stat-chip-label">Files analyzed</div>
22954 <div class="stat-chip-val">{{ files_analyzed }}</div>
22955 <div class="stat-chip-exact"></div>
22956 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
22957 </div>
22958 {% if cyclomatic_complexity > 0 %}
22959 <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 %}>
22960 <div class="stat-chip-label">Complexity score</div>
22961 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
22962 <div class="stat-chip-exact"></div>
22963 <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>
22964 </div>
22965 {% endif %}
22966 {% if let Some(ls) = lsloc %}
22967 <div class="stat-chip" data-raw="{{ ls }}">
22968 <div class="stat-chip-label">Logical SLOC</div>
22969 <div class="stat-chip-val">{{ ls }}</div>
22970 <div class="stat-chip-exact"></div>
22971 <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>
22972 </div>
22973 {% endif %}
22974 {% if uloc > 0 %}
22975 <div class="stat-chip" data-raw="{{ uloc }}">
22976 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
22977 <div class="stat-chip-val">{{ uloc }}</div>
22978 <div class="stat-chip-exact"></div>
22979 <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>
22980 </div>
22981 {% endif %}
22982 {% if uloc > 0 && dryness_pct_str != "" %}
22983 <div class="stat-chip">
22984 <div class="stat-chip-label">DRYness</div>
22985 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
22986 <div class="stat-chip-exact"></div>
22987 <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>
22988 </div>
22989 {% endif %}
22990 {% if duplicate_group_count > 0 %}
22991 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
22992 <div class="stat-chip-label">Duplicate groups</div>
22993 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
22994 <div class="stat-chip-exact"></div>
22995 <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>
22996 </div>
22997 {% endif %}
22998 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
22999 odd, so the strip always forms exactly two full rows with every column
23000 aligned and every card the same width (no oversized card, no gap). -->
23001 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23002 <div class="stat-chip-label">Assertions</div>
23003 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23004 <div class="stat-chip-exact"></div>
23005 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23006 </div>
23007 </div>
23008
23009 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23010 <div class="compare-banner">
23011 <div class="compare-banner-body">
23012 <div class="compare-banner-top">
23013 <div class="compare-banner-meta">
23014 <span class="compare-label">Previous scan</span>
23015 <span class="compare-ts">{{ prev_ts }}</span>
23016 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23017 {% if let Some(prev_code) = prev_run_code_lines %}
23018 <div class="compare-banner-stats" style="margin-top:4px;">
23019 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23020 <span class="compare-arrow">→</span>
23021 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23022 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23023 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23024 </div>
23025 {% endif %}
23026 </div>
23027 {% if delta_lines_added.is_some() %}
23028 <div class="delta-cards-inline">
23029 <div class="delta-card-inline">
23030 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23031 <div class="delta-card-lbl">lines added</div>
23032 <div class="delta-card-tip">Code lines added since the previous scan</div>
23033 </div>
23034 <div class="delta-card-inline">
23035 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23036 <div class="delta-card-lbl">lines removed</div>
23037 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23038 </div>
23039 <div class="delta-card-inline">
23040 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23041 <div class="delta-card-lbl">unmodified lines</div>
23042 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23043 </div>
23044 <div class="delta-card-inline">
23045 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23046 <div class="delta-card-lbl">files modified</div>
23047 <div class="delta-card-tip">Files with at least one line changed</div>
23048 </div>
23049 <div class="delta-card-inline">
23050 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23051 <div class="delta-card-lbl">files added</div>
23052 <div class="delta-card-tip">New files added since the previous scan</div>
23053 </div>
23054 <div class="delta-card-inline">
23055 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23056 <div class="delta-card-lbl">files removed</div>
23057 <div class="delta-card-tip">Files deleted since the previous scan</div>
23058 </div>
23059 <div class="delta-card-inline">
23060 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23061 <div class="delta-card-lbl">files unchanged</div>
23062 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23063 </div>
23064 </div>
23065 {% else %}
23066 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23067 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23068 </p>
23069 {% endif %}
23070 </div>
23071 <div class="compare-banner-actions">
23072 <div class="compare-banner-actions-left">
23073 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23074 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23075 </div>
23076 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23077 </div>
23078 </div>
23079 </div>
23080 {% endif %}{% endif %}
23081
23082 <div class="action-grid">
23083 <div class="action-card">
23084 <h3>HTML report</h3>
23085 <div class="action-buttons">
23086 {% match html_url %}
23087 {% when Some with (url) %}
23088 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23089 {% when None %}{% endmatch %}
23090 {% match html_download_url %}
23091 {% when Some with (url) %}
23092 <a class="button secondary" href="{{ url }}">Download HTML</a>
23093 {% when None %}{% endmatch %}
23094 {% match html_path %}
23095 {% when Some with (_path) %}{% when None %}{% endmatch %}
23096 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23097 </div>
23098 </div>
23099 <div class="action-card">
23100 <h3>PDF report</h3>
23101 <div class="action-buttons">
23102 {% match pdf_url %}
23103 {% when Some with (url) %}
23104 {% if pdf_generating %}
23105 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23106 <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>
23107 Generating PDF…
23108 </button>
23109 {% else %}
23110 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23111 {% endif %}
23112 {% when None %}
23113 {% match html_url %}
23114 {% when Some with (_hurl) %}
23115 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23116 <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>
23117 {% when None %}
23118 <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;">
23119 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23120 </p>
23121 {% endmatch %}
23122 {% endmatch %}
23123 {% match pdf_download_url %}
23124 {% when Some with (url) %}
23125 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23126 {% when None %}{% endmatch %}
23127 {% match pdf_url %}
23128 {% when Some with (_) %}
23129 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23130 {% when None %}{% endmatch %}
23131 </div>
23132 </div>
23133 <div class="action-card">
23134 <h3>JSON result</h3>
23135 <div class="action-buttons">
23136 {% match json_url %}
23137 {% when Some with (url) %}
23138 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23139 {% when None %}{% endmatch %}
23140 {% match json_download_url %}
23141 {% when Some with (url) %}
23142 <a class="button secondary" href="{{ url }}">Download JSON</a>
23143 {% when None %}{% endmatch %}
23144 {% match json_path %}
23145 {% when Some with (_path) %}
23146 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23147 {% when None %}
23148 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23149 {% endmatch %}
23150 </div>
23151 </div>
23152 <div class="action-card">
23153 <h3>Scan config</h3>
23154 <div class="action-buttons">
23155 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23156 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23157 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23158 </div>
23159 </div>
23160 {% if confluence_configured %}
23161 <div class="action-card" id="confluenceCard">
23162 <h3>Confluence</h3>
23163 <div class="action-buttons">
23164 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23165 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23166 </div>
23167 <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>
23168 </div>
23169 {% endif %}
23170 </div>
23171 {% if confluence_configured %}
23172 <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;">
23173 <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);">
23174 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23175 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23176 <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;">
23177 <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>
23178 <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;">
23179 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23180 <div style="display:flex;gap:10px;justify-content:flex-end;">
23181 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23182 <button class="button" id="confSubmitBtn" type="button">Post</button>
23183 </div>
23184 </div>
23185 </div>
23186 {% endif %}
23187 <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;">
23188 <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);">
23189 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23190 <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>
23191 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23192 <div style="display:flex;gap:18px;justify-content:flex-end;">
23193 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23194 <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>
23195 </div>
23196 </div>
23197 </div>
23198 {% if !submodule_rows.is_empty() %}
23199 <div class="submodule-panel">
23200 <div class="toolbar-row">
23201 <div>
23202 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23203 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23204 </div>
23205 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23206 </div>
23207 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23208 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23209 <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>
23210 <thead>
23211 <tr>
23212 <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>
23213 <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>
23214 <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>
23215 <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>
23216 <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>
23217 <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>
23218 <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>
23219 <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>
23220 </tr>
23221 </thead>
23222 <tbody>
23223 {% for row in submodule_rows %}
23224 <tr>
23225 <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>
23226 <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>
23227 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23228 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23229 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23230 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23231 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23232 <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>
23233 </tr>
23234 {% endfor %}
23235 </tbody>
23236 </table>
23237 </div>
23238 </div>
23239 {% endif %}
23240
23241 <div class="metrics-tables-stack">
23242
23243 <div class="metrics-table-wrap">
23244 <div class="metrics-table-title">Files</div>
23245 <table class="metrics-table">
23246 <thead>
23247 <tr>
23248 <th>Metric</th>
23249 <th>This Run</th>
23250 <th>Previous</th>
23251 <th>Change</th>
23252 </tr>
23253 </thead>
23254 <tbody>
23255 <tr>
23256 <td>Files analyzed</td>
23257 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23258 <td>{{ prev_fa_str|commas }}</td>
23259 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23260 </tr>
23261 <tr>
23262 <td>Files skipped</td>
23263 <td>{{ files_skipped|commas }}</td>
23264 <td>{{ prev_fs_str|commas }}</td>
23265 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23266 </tr>
23267 <tr>
23268 <td>Files modified</td>
23269 <td class="mt-val-na">—</td>
23270 <td class="mt-val-na">—</td>
23271 <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>
23272 </tr>
23273 <tr>
23274 <td>Files unchanged</td>
23275 <td class="mt-val-na">—</td>
23276 <td class="mt-val-na">—</td>
23277 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23278 </tr>
23279 </tbody>
23280 </table>
23281 </div>
23282
23283 <div class="metrics-table-wrap">
23284 <div class="metrics-table-title">Line Counts</div>
23285 <table class="metrics-table">
23286 <thead>
23287 <tr>
23288 <th>Metric</th>
23289 <th>This Run</th>
23290 <th>Previous</th>
23291 <th>Change</th>
23292 </tr>
23293 </thead>
23294 <tbody>
23295 <tr>
23296 <td>Physical lines</td>
23297 <td class="mt-val-large">{{ physical_lines|commas }}</td>
23298 <td>{{ prev_pl_str|commas }}</td>
23299 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23300 </tr>
23301 <tr>
23302 <td>Code lines</td>
23303 <td class="mt-val-large">{{ code_lines|commas }}</td>
23304 <td>{{ prev_cl_str|commas }}</td>
23305 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23306 </tr>
23307 <tr>
23308 <td>Comment lines</td>
23309 <td>{{ comment_lines|commas }}</td>
23310 <td>{{ prev_cml_str|commas }}</td>
23311 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23312 </tr>
23313 <tr>
23314 <td>Blank lines</td>
23315 <td>{{ blank_lines|commas }}</td>
23316 <td>{{ prev_bl_str|commas }}</td>
23317 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23318 </tr>
23319 <tr>
23320 <td>Mixed (separate)</td>
23321 <td>{{ mixed_lines|commas }}</td>
23322 <td class="mt-val-na">—</td>
23323 <td class="mt-val-na">—</td>
23324 </tr>
23325 </tbody>
23326 </table>
23327 </div>
23328
23329 <div class="metrics-tables-lower">
23330 <div class="metrics-table-wrap">
23331 <div class="metrics-table-title">Code Structure</div>
23332 <table class="metrics-table">
23333 <thead>
23334 <tr>
23335 <th>Metric</th>
23336 <th>This Run</th>
23337 </tr>
23338 </thead>
23339 <tbody>
23340 <tr>
23341 <td>Functions</td>
23342 <td>{{ functions|commas }}</td>
23343 </tr>
23344 <tr>
23345 <td>Classes / Types</td>
23346 <td>{{ classes|commas }}</td>
23347 </tr>
23348 <tr>
23349 <td>Variables</td>
23350 <td>{{ variables|commas }}</td>
23351 </tr>
23352 <tr>
23353 <td>Imports</td>
23354 <td>{{ imports|commas }}</td>
23355 </tr>
23356 </tbody>
23357 </table>
23358 </div>
23359
23360 <div class="metrics-table-wrap">
23361 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23362 <table class="metrics-table">
23363 <thead>
23364 <tr>
23365 <th>Metric</th>
23366 <th>Change</th>
23367 </tr>
23368 </thead>
23369 <tbody>
23370 <tr>
23371 <td>Lines added</td>
23372 <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>
23373 </tr>
23374 <tr>
23375 <td>Lines removed</td>
23376 <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>
23377 </tr>
23378 <tr>
23379 <td>Lines modified (net)</td>
23380 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23381 </tr>
23382 <tr>
23383 <td>Lines unmodified</td>
23384 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23385 </tr>
23386 </tbody>
23387 </table>
23388 </div>
23389 </div>
23390
23391 </div>
23392
23393 <div class="path-list">
23394 <div class="path-item">
23395 <div class="path-item-label">Project path</div>
23396 {% 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 %}
23397 </div>
23398 <div class="path-item">
23399 <div class="path-item-label">Git branch</div>
23400 {% if let Some(branch) = git_branch %}
23401 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23402 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23403 {% else %}
23404 <code style="color:var(--muted)">—</code>
23405 {% endif %}
23406 </div>
23407 <div class="path-item">
23408 <div class="path-item-label">Output folder</div>
23409 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23410 </div>
23411 <div class="path-item">
23412 <div class="path-item-label">Run ID</div>
23413 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23414 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23415 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23416 </div>
23417 </div>
23418 </div>
23419 </section>
23420
23421 {% if has_cocomo %}
23422 <div class="cocomo-box" style="margin-top:24px;">
23423 <div class="cocomo-box-head">
23424 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
23425 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23426 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23427 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23428 </span>
23429 </div>
23430 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23431 <div class="stat-chip">
23432 <div class="stat-chip-label">Person-months</div>
23433 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23434 <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>
23435 </div>
23436 <div class="stat-chip">
23437 <div class="stat-chip-label">Schedule (months)</div>
23438 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23439 <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>
23440 </div>
23441 <div class="stat-chip">
23442 <div class="stat-chip-label">Avg. Team Size</div>
23443 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23444 <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>
23445 </div>
23446 <div class="stat-chip">
23447 <div class="stat-chip-label">Input KSLOC</div>
23448 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23449 <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>
23450 </div>
23451 </div>
23452 <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>
23453 </div>
23454 {% endif %}
23455
23456 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23457 <div class="cocomo-box" style="margin-top:24px;">
23458 <div class="cocomo-box-head">
23459 <span class="cocomo-box-title">Tests & Coverage</span>
23460 {% if has_coverage_data %}
23461 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23462 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23463 </span>
23464 {% endif %}
23465 </div>
23466 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23467 <div class="stat-chip">
23468 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23469 <div class="stat-chip-label">Test Functions</div>
23470 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23471 </div>
23472 <div class="stat-chip">
23473 {% if has_coverage_data %}
23474 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23475 {% else %}
23476 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23477 {% endif %}
23478 <div class="stat-chip-label">Line Coverage</div>
23479 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23480 </div>
23481 <div class="stat-chip">
23482 {% if !cov_fn_pct.is_empty() %}
23483 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23484 {% else %}
23485 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23486 {% endif %}
23487 <div class="stat-chip-label">Fn Coverage</div>
23488 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23489 </div>
23490 <div class="stat-chip">
23491 {% if !cov_branch_pct.is_empty() %}
23492 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23493 {% else %}
23494 <div class="stat-chip-val" style="color:var(--muted);">—</div>
23495 {% endif %}
23496 <div class="stat-chip-label">Branch Coverage</div>
23497 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23498 </div>
23499 </div>
23500 {% if has_coverage_data %}
23501 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
23502 {% else %}
23503 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
23504 {% endif %}
23505 </div>
23506
23507 <div class="section-pair">
23508 <section class="panel">
23509 <div class="toolbar-row">
23510 <div>
23511 <h2>Language Breakdown</h2>
23512 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23513 </div>
23514 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23515 </div>
23516 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23517 </section>
23518
23519 <section class="panel r-chart-section">
23520 <div class="toolbar-row" style="margin-bottom:16px;">
23521 <div>
23522 <h2>Visualizations</h2>
23523 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23524 </div>
23525 </div>
23526
23527 <div class="r-viz-grid">
23528 <div class="r-viz-card">
23529 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23530 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23531 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23532 </div>
23533 <div class="r-chart-tab-bar">
23534 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23535 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23536 </div>
23537 <div class="r-chart-container" id="r-composition-chart"></div>
23538 </div>
23539 <div class="r-viz-card">
23540 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23541 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23542 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23543 </div>
23544 <div class="r-chart-container" id="r-scatter-chart"></div>
23545 </div>
23546 {% if has_semantic_data %}
23547 <div class="r-viz-card">
23548 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23549 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23550 <select class="r-chart-select" id="r-semantic-metric">
23551 <option value="functions">Functions</option>
23552 <option value="classes">Classes</option>
23553 <option value="variables">Variables</option>
23554 <option value="imports">Imports</option>
23555 <option value="tests">Tests</option>
23556 </select>
23557 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23558 </div>
23559 <div class="r-chart-container" id="r-semantic-chart"></div>
23560 </div>
23561 {% endif %}
23562 <div class="r-viz-card">
23563 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23564 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23565 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23566 </div>
23567 <div class="r-chart-container" id="r-density-chart"></div>
23568 </div>
23569 <div class="r-viz-card">
23570 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23571 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23572 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23573 </div>
23574 <div class="r-chart-container" id="r-avglines-chart"></div>
23575 </div>
23576 <div class="r-viz-card">
23577 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23578 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23579 <select class="r-chart-select" id="r-sub-metric">
23580 <option value="code">Code Lines</option>
23581 <option value="comment">Comments</option>
23582 <option value="blank">Blank Lines</option>
23583 <option value="physical">Physical Lines</option>
23584 <option value="files">Files</option>
23585 </select>
23586 <select class="r-chart-select" id="r-sub-sort">
23587 <option value="desc">Value ↓</option>
23588 <option value="asc">Value ↑</option>
23589 <option value="name">Name A→Z</option>
23590 </select>
23591 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
23592 </div>
23593 <div class="r-chart-container" id="r-submodule-chart"></div>
23594 </div>
23595 </div>
23596
23597 </section>
23598 </div>
23599
23600 </div>
23601
23602 <div id="r-tt" aria-hidden="true"></div>
23603
23604 <script nonce="{{ csp_nonce }}">
23605 (function () {
23606 var body = document.body;
23607 var themeToggle = document.getElementById('theme-toggle');
23608 var storageKey = 'oxide-sloc-theme';
23609
23610 function applyTheme(theme) {
23611 body.classList.toggle('dark-theme', theme === 'dark');
23612 }
23613
23614 function loadSavedTheme() {
23615 try {
23616 var saved = localStorage.getItem(storageKey);
23617 if (saved === 'dark' || saved === 'light') {
23618 applyTheme(saved);
23619 }
23620 } catch (e) {}
23621 }
23622
23623 if (themeToggle) {
23624 themeToggle.addEventListener('click', function () {
23625 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23626 applyTheme(nextTheme);
23627 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23628 });
23629 }
23630
23631 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23632 button.addEventListener('click', function () {
23633 var value = button.getAttribute('data-copy-value') || '';
23634 if (!value) return;
23635 var originalText = button.textContent;
23636 function flashSuccess() {
23637 button.textContent = 'Copied!';
23638 setTimeout(function () { button.textContent = originalText; }, 1800);
23639 }
23640 function flashFail() {
23641 button.textContent = 'Copy failed';
23642 setTimeout(function () { button.textContent = originalText; }, 2000);
23643 }
23644 if (navigator.clipboard && navigator.clipboard.writeText) {
23645 navigator.clipboard.writeText(value).then(flashSuccess, function () {
23646 fallbackCopy(value, flashSuccess, flashFail);
23647 });
23648 } else {
23649 fallbackCopy(value, flashSuccess, flashFail);
23650 }
23651 });
23652 });
23653 function fallbackCopy(text, onSuccess, onFail) {
23654 try {
23655 var ta = document.createElement('textarea');
23656 ta.value = text;
23657 ta.style.position = 'fixed';
23658 ta.style.top = '-9999px';
23659 ta.style.left = '-9999px';
23660 document.body.appendChild(ta);
23661 ta.focus();
23662 ta.select();
23663 var ok = document.execCommand('copy');
23664 document.body.removeChild(ta);
23665 if (ok) { onSuccess(); } else { onFail(); }
23666 } catch (e) { onFail(); }
23667 }
23668
23669 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23670 btn.addEventListener('click', function () {
23671 var folder = btn.getAttribute('data-folder') || '';
23672 if (!folder) return;
23673 var orig = btn.textContent;
23674 fetch('/open-path?path=' + encodeURIComponent(folder))
23675 .then(function (r) { return r.json(); })
23676 .then(function (d) {
23677 if (d && d.server_mode_disabled) {
23678 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23679 } else if (d && d.ok) {
23680 btn.textContent = 'Opened!';
23681 setTimeout(function () { btn.textContent = orig; }, 1800);
23682 }
23683 })
23684 .catch(function () {
23685 btn.textContent = 'Failed';
23686 setTimeout(function () { btn.textContent = orig; }, 2000);
23687 });
23688 });
23689 });
23690
23691 loadSavedTheme();
23692
23693 // ── Compact number formatting for stat chips ──────────────────────────
23694 (function(){
23695 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();}
23696 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23697 var raw=parseInt(chip.getAttribute('data-raw'),10);
23698 if(isNaN(raw))return;
23699 var valEl=chip.querySelector('.stat-chip-val');
23700 if(valEl)valEl.textContent=fmt(raw);
23701 var exactEl=chip.querySelector('.stat-chip-exact');
23702 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23703 });
23704 // Code density chip
23705 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23706 var code=parseInt(chip.getAttribute('data-code'),10);
23707 var phys=parseInt(chip.getAttribute('data-physical'),10);
23708 if(isNaN(code)||isNaN(phys)||phys===0)return;
23709 var pct=(code/phys*100).toFixed(1)+'%';
23710 var valEl=chip.querySelector('.stat-chip-val');
23711 if(valEl)valEl.textContent=pct;
23712 });
23713 // Populate author handle from data-author attribute
23714 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23715 var author=chip.getAttribute('data-author');
23716 var el=chip.querySelector('.author-handle');
23717 if(el)el.textContent='/'+author.replace(/\s+/g,'');
23718 });
23719 // Click-to-copy on run-id-chip elements
23720 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23721 chip.addEventListener('click',function(){
23722 var val=chip.getAttribute('data-copy');
23723 if(!val)return;
23724 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23725 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);}
23726 chip.classList.add('chip-copied-flash');
23727 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23728 });
23729 });
23730 // Format delta card values with data-raw using comma-separated full numbers
23731 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23732 var raw=parseInt(card.getAttribute('data-raw'),10);
23733 if(isNaN(raw))return;
23734 var valEl=card.querySelector('.delta-card-val');
23735 if(valEl)valEl.textContent=raw.toLocaleString();
23736 });
23737 // Format code-before / code-now numbers in the compare banner stats line
23738 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23739 var raw=parseInt(el.getAttribute('data-raw'),10);
23740 if(!isNaN(raw))el.textContent=raw.toLocaleString();
23741 });
23742 })();
23743
23744 // ── Shared tooltip for all result-page charts ─────────────────────────
23745 var rTT=(function(){
23746 var el=document.getElementById('r-tt');
23747 if(!el)return{s:function(){},h:function(){},m:function(){}};
23748 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23749 function hide(){el.style.display='none';}
23750 function move(e){
23751 var x=e.clientX+16,y=e.clientY-12;
23752 var r=el.getBoundingClientRect();
23753 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23754 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23755 el.style.left=x+'px';el.style.top=y+'px';
23756 }
23757 return{s:show,h:hide,m:move};
23758 })();
23759 window.rTT=rTT;
23760
23761 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23762 (function(){
23763 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23764 document.addEventListener('mouseover',function(e){
23765 var t=e.target;
23766 while(t&&t.getAttribute){
23767 var l=t.getAttribute('data-ttl');
23768 if(l!==null){
23769 var v=t.getAttribute('data-ttv')||'';
23770 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23771 return;
23772 }
23773 t=t.parentNode;
23774 }
23775 });
23776 document.addEventListener('mouseout',function(e){
23777 var t=e.target;
23778 while(t&&t.getAttribute){
23779 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23780 t=t.parentNode;
23781 }
23782 });
23783 document.addEventListener('mousemove',function(e){
23784 var el=document.getElementById('r-tt');
23785 if(el&&el.style.display!=='none')rTT.m(e);
23786 });
23787 window.addEventListener('blur',function(){rTT.h();});
23788 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23789 })();
23790
23791 // ── Language overview charts ───────────────────────────────────────────
23792 (function(){
23793 var D={{ lang_chart_json|safe }};
23794 if(!D||!D.length)return;
23795 var el=document.getElementById('result-lang-charts');
23796 if(!el)return;
23797 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23798 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23799 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23800 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();}
23801 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23802 function px(n){return Math.round(n);}
23803 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+'"';}
23804 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23805 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23806 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23807 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;}
23808 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23809
23810 // Donut chart — height matches the stacked-bar chart so both panels align
23811 var rHb_d=28;
23812 var DH=Math.max(220,D.length*rHb_d+32);
23813 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23814 var legX=208,DW=395;
23815 var legCount=D.length;
23816 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23817 var legYStart=Math.round((DH-legCount*legSpacing)/2);
23818 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">';
23819 if(D.length===1){
23820 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23821 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+'"/>';
23822 } else {
23823 var smalls=[];
23824 var ang=-Math.PI/2;
23825 D.forEach(function(d,i){
23826 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23827 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23828 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23829 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23830 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23831 var pct=Math.round(d.code/tot*100);
23832 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"/>';
23833 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]});}
23834 ang+=sw;
23835 });
23836 // Small slices (<5%) get outside labels positioned near each slice's own
23837 // angular position (a slice on the left gets its label/leader on the left),
23838 // then nudged apart horizontally so text never overlaps. Leader lines point
23839 // from each slice to its label. Horizontal text keeps long names legible;
23840 // the whole SVG scales up in Full View so these stay readable there too.
23841 if(smalls.length){
23842 smalls.sort(function(a,b){return a.mAng-b.mAng;});
23843 var sPad=6,sRowY=11;
23844 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)));});
23845 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;}
23846 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23847 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23848 smalls.forEach(function(sm){
23849 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23850 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;"/>';
23851 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>';
23852 });
23853 }
23854 }
23855 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23856 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23857 D.forEach(function(d,i){
23858 var ly=legYStart+i*legSpacing;
23859 var pctL=Math.round(d.code/tot*100);
23860 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
23861 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
23862 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23863 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23864 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23865 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23866 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>';
23867 ds+='</g>';
23868 });
23869 ds+='</svg>';
23870
23871 // Horizontal stacked-bar chart — fills container width
23872 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23873 var LW=108,BW=260,svgW=LW+BW+68;
23874 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23875 var barBH=Math.min(32,Math.round(barRhb*0.7));
23876 var SH=DH;
23877 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23878 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">';
23879 D.forEach(function(d,i){
23880 var y=barTopPad+i*barRhb,x=LW;
23881 var phys=d.physical||d.code+d.comments+d.blanks;
23882 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23883 var lmid=y+barBH/2+4;
23884 // Combined breakdown shown when hovering the row, the language name, or the
23885 // total at the bar end (\n becomes a line break in the tooltip).
23886 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23887 bs+='<g class="lang-bar-row">';
23888 // Hit area ends just past the total label so empty space to the right of the
23889 // bar does not trigger the tooltip — only the name, bar and total are hot.
23890 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
23891 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
23892 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>';
23893 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;}
23894 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;}
23895 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>';}
23896 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>';
23897 bs+='</g>';
23898 });
23899 var ly=SH-14;
23900 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
23901 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
23902 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
23903 var totAll=totC+totCm+totBl||1;
23904 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
23905 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
23906 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
23907 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
23908 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
23909 bs+='<g data-kind="code" style="cursor:pointer;">'
23910 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
23911 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
23912 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
23913 +'</g>';
23914 bs+='<g data-kind="comment" style="cursor:pointer;">'
23915 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
23916 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
23917 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
23918 +'</g>';
23919 bs+='<g data-kind="blank" style="cursor:pointer;">'
23920 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
23921 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
23922 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
23923 +'</g>';
23924 bs+='</svg>';
23925 el.innerHTML='<div class="r-lang-overview">'+
23926 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
23927 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
23928 '</div>';
23929 function wireDonutLegend(svg){
23930 if(!svg)return;
23931 var paths=svg.querySelectorAll('path[data-lang]');
23932 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';}}}
23933 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
23934 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();});
23935 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();});
23936 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
23937 }
23938 function wireMixLegend(svg){
23939 if(!svg)return;
23940 var legGs=svg.querySelectorAll('g[data-kind]');
23941 var allRects=svg.querySelectorAll('rect[data-kind]');
23942 if(!legGs.length)return;
23943 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';}}
23944 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='';}}
23945 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]);}
23946 }
23947 wireDonutLegend(el.querySelector('svg'));
23948 wireMixLegend(el.querySelectorAll('svg')[1]);
23949
23950 // ── Language breakdown Full View expand ─────────────────────────────────
23951 var langOvBtn=document.getElementById('result-lang-overview-expand');
23952 if(langOvBtn){langOvBtn.addEventListener('click',function(){
23953 var src=document.getElementById('result-lang-charts');
23954 if(!src)return;
23955 var overlay=document.createElement('div');
23956 overlay.className='r-chart-modal-overlay';
23957 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>';
23958 document.body.appendChild(overlay);
23959 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
23960 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
23961 var wrap=document.getElementById('result-lang-overview-modal-wrap');
23962 if(wrap){
23963 wrap.innerHTML=src.innerHTML;
23964 var svgs=wrap.querySelectorAll('svg');
23965 for(var i=0;i<svgs.length;i++){
23966 svgs[i].removeAttribute('width');
23967 svgs[i].removeAttribute('height');
23968 svgs[i].style.cssText='display:block;width:100%;height:auto;';
23969 }
23970 var ov=wrap.querySelector('.r-lang-overview');
23971 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
23972 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
23973 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
23974 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
23975 wireDonutLegend(wrap.querySelector('svg'));
23976 wireMixLegend(wrap.querySelectorAll('svg')[1]);
23977 requestAnimationFrame(function(){
23978 var ss=wrap.querySelectorAll('svg');
23979 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%;';}}
23980 });
23981 }
23982 });}
23983 })();
23984
23985 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
23986 (function(){
23987 var LANG_D={{ lang_chart_json|safe }};
23988 var SCAT_D={{ scatter_chart_json|safe }};
23989 var SEM_D={{ semantic_chart_json|safe }};
23990 var SUB_D={{ submodule_chart_json|safe }};
23991 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
23992 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23993 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();}
23994 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
23995 function px(n){return Math.round(n);}
23996 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+'"';}
23997 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
23998 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
23999 // rather than disappear; the SVG scales up in Full View).
24000 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;}
24001
24002 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24003 function renderCompositionInEl(el,mode,shOvr){
24004 if(!el||!LANG_D||!LANG_D.length)return;
24005 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24006 var LW=110,SH=shOvr||300;
24007 var svgW=Math.max(320,el.offsetWidth||480);
24008 var BW=Math.max(120,svgW-LW-80);
24009 var legendH=24,topPad=4;
24010 var n=LANG_D.length||1;
24011 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24012 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24013 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">';
24014 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24015 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24016 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24017 var totAll2=totC2+totCm2+totBl2||1;
24018 if(mode==='pct'){
24019 LANG_D.forEach(function(d,i){
24020 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24021 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24022 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24023 var lmid=y+Math.floor(bH/2)+4;
24024 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24025 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>';
24026 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;}
24027 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;}
24028 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>';}
24029 var pct=Math.round((d.code||0)/tot2*100);
24030 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>';
24031 });
24032 } else {
24033 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24034 LANG_D.forEach(function(d,i){
24035 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24036 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24037 var lmid=y+Math.floor(bH/2)+4;
24038 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));
24039 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>';
24040 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;}
24041 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;}
24042 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>';}
24043 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>';
24044 });
24045 }
24046 var ly=SH-legendH+4;
24047 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24048 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24049 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24050 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24051 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24052 s+='<g data-kind="code" style="cursor:pointer;">'
24053 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24054 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24055 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24056 +'</g>';
24057 s+='<g data-kind="comment" style="cursor:pointer;">'
24058 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24059 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24060 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24061 +'</g>';
24062 s+='<g data-kind="blank" style="cursor:pointer;">'
24063 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24064 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24065 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24066 +'</g>';
24067 s+='</svg>';
24068 el.innerHTML=s;
24069 wireMixLegendEl(el);
24070 }
24071 function wireMixLegendEl(container){
24072 var svg=container&&container.querySelector('svg');
24073 if(!svg)return;
24074 var legGs=svg.querySelectorAll('g[data-kind]');
24075 var allRects=svg.querySelectorAll('rect[data-kind]');
24076 if(!legGs.length)return;
24077 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';}}
24078 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='';}}
24079 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]);}
24080 }
24081 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24082 renderComposition('abs');
24083 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24084 btn.addEventListener('click',function(){
24085 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24086 btn.classList.add('active');
24087 renderComposition(btn.getAttribute('data-rcomp'));
24088 });
24089 });
24090
24091 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24092 function wireScatterLegend(container){
24093 var svg=container&&container.querySelector('svg');
24094 if(!svg)return;
24095 var legGs=svg.querySelectorAll('g[data-lang]');
24096 var circs=svg.querySelectorAll('circle[data-lang]');
24097 if(!legGs.length)return;
24098 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';}}
24099 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24100 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='';}}
24101 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]);}
24102 }
24103 function renderScatterInEl(el,hOvr){
24104 if(!el||!SCAT_D||!SCAT_D.length)return;
24105 var n=SCAT_D.length;
24106 var H=hOvr||300,PL=52,PB=36,PT=44;
24107 var W=Math.max(320,el.offsetWidth||480);
24108 var cH=H-PT-PB;
24109 // Legend: max 2 columns, fills vertical space. The compact card shows the
24110 // top languages by code lines plus a "+N more" row linking to Full View;
24111 // Full View (hOvr set) shows every language across up to 2 tall columns.
24112 var compact=!hOvr;
24113 var availH=Math.max(120,H-24);
24114 var rowsFit=Math.max(2,Math.floor(availH/18));
24115 var legTrunc=compact&&(n>2*rowsFit);
24116 var legShown=legTrunc?(2*rowsFit-1):n;
24117 var legTotal=legTrunc?(2*rowsFit):n;
24118 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24119 var legPerCol=Math.ceil(legTotal/legCols);
24120 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24121 var legColW=hOvr?144:130;
24122 var LG=26;
24123 var legW=legCols*legColW;
24124 var cW=W-PL-LG-legW;
24125 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24126 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24127 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24128 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24129 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24130 var logMaxF=Math.log1p(maxF);
24131 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">';
24132 // Y grid lines (linear)
24133 [0,0.25,0.5,0.75,1].forEach(function(t){
24134 var y=PT+cH*(1-t);
24135 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24136 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>';
24137 });
24138 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24139 [0,0.25,0.5,0.75,1].forEach(function(t){
24140 var x=PL+cW*t;
24141 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24142 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24143 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>';
24144 });
24145 // Full View (hOvr set) has the vertical room to show the per-bubble value
24146 // line; the compact card shows only the language label to avoid the
24147 // overlapping-label clutter seen when bubbles cluster together.
24148 var showVal=!!hOvr;
24149 SCAT_D.forEach(function(d,i){
24150 // X uses log1p so outlier languages (many files) don't push others to the far left
24151 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24152 var cy2=PT+cH-d.code/maxC*cH;
24153 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24154 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"/>';
24155 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24156 if(showVal){
24157 var ty2=Math.max(24,px(cy2)-px(r)-3);
24158 var ty1=Math.max(12,ty2-14);
24159 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>';
24160 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>';
24161 }else{
24162 var ly2=Math.max(12,px(cy2)-px(r)-3);
24163 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>';
24164 }
24165 });
24166 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>';
24167 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>';
24168 // Legend (right side — top languages, max 2 columns, fills height)
24169 var legX=PL+cW+LG;
24170 var legBlockH=legPerCol*legRowH;
24171 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24172 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24173 for(var lk=0;lk<legShown;lk++){
24174 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24175 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24176 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;">';
24177 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24178 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24179 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>';
24180 s+='</g>';
24181 }
24182 if(legTrunc){
24183 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24184 s+='<g data-more="1" style="cursor:pointer;">';
24185 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24186 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24187 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>';
24188 s+='</g>';
24189 }
24190 s+='</svg>';
24191 el.innerHTML=s;
24192 wireScatterLegend(el);
24193 var moreEl=el.querySelector('g[data-more]');
24194 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24195 }
24196 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24197
24198 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24199 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24200 // the old vertical column layout on wide containers.
24201 function renderSemanticInEl(el,key,sh){
24202 if(!el||!SEM_D||!SEM_D.length)return;
24203 var n2=SEM_D.length||1;
24204 var LW=112,SH=sh||Math.max(180,n2*28+26);
24205 var svgW=Math.max(320,el.offsetWidth||480);
24206 var BW=Math.max(120,svgW-LW-80);
24207 var topPad=4,botPad=14;
24208 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24209 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24210 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24211 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">';
24212 SEM_D.forEach(function(d,i){
24213 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24214 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>';
24215 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"/>';
24216 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>';
24217 });
24218 s+='</svg>';
24219 el.innerHTML=s;
24220 }
24221 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24222 var semSel=document.getElementById('r-semantic-metric');
24223 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24224 var semExpand=document.getElementById('r-semantic-expand');
24225 if(semExpand){
24226 semExpand.addEventListener('click',function(){
24227 var key=semSel?semSel.value:'functions';
24228 var n=SEM_D.length||1;
24229 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24230 var modalH=Math.min(Math.max(360,n*38+60),maxH);
24231 var overlay=document.createElement('div');
24232 overlay.className='r-chart-modal-overlay';
24233 var optHtml=
24234 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24235 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24236 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24237 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24238 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24239 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>';
24240 document.body.appendChild(overlay);
24241 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24242 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24243 var modalEl=document.getElementById('r-sem-modal-chart');
24244 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24245 var modalSel=document.getElementById('r-sem-modal-metric');
24246 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24247 });
24248 }
24249
24250 // ── Expand buttons: re-render charts at large size inside modal ──────────
24251 (function(){
24252 function makeExpandModal(title,mH,subtitle,ctrlHtml){
24253 var overlay=document.createElement('div');
24254 overlay.className='r-chart-modal-overlay';
24255 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24256 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24257 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>';
24258 document.body.appendChild(overlay);
24259 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24260 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24261 return overlay.querySelector('.r-expand-modal-chart');
24262 }
24263 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24264 var compExpandBtn=document.getElementById('r-composition-expand');
24265 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24266 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24267 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24268 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24269 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24270 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24271 if(wrap){
24272 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24273 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24274 btn.addEventListener('click',function(){
24275 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24276 btn.classList.add('active');
24277 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24278 });
24279 });
24280 }
24281 });}
24282 var scatExpandBtn=document.getElementById('r-scatter-expand');
24283 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24284 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24285 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24286 });}
24287 var densExpandBtn=document.getElementById('r-density-expand');
24288 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24289 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24290 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24291 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24292 });}
24293 var avgExpandBtn=document.getElementById('r-avglines-expand');
24294 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24295 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24296 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24297 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24298 });}
24299 var subExpandBtn=document.getElementById('r-submodule-expand');
24300 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24301 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24302 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24303 var metCtrl=
24304 '<select class="r-chart-select" id="r-sub-modal-metric">'
24305 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24306 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24307 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24308 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24309 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24310 +'</select>';
24311 var sortCtrl=
24312 '<select class="r-chart-select" id="r-sub-modal-sort">'
24313 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24314 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24315 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24316 +'</select>';
24317 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24318 if(wrap){
24319 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24320 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24321 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24322 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24323 if(mSub)mSub.addEventListener('change',reRenderSub);
24324 if(mSort)mSort.addEventListener('change',reRenderSub);
24325 }
24326 });}
24327 })();
24328
24329 // ── Comment Density: comments / (code + comments) per language ───────────
24330 function renderDensityInEl(el,shOvr){
24331 if(!el||!LANG_D||!LANG_D.length)return;
24332 var n=LANG_D.length||1;
24333 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24334 var svgW=Math.max(320,el.offsetWidth||480);
24335 var BW=Math.max(120,svgW-LW-80);
24336 var topPad=4,botPad=26;
24337 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24338 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24339 var densities=LANG_D.map(function(d){
24340 var sig=(d.code||0)+(d.comments||0);
24341 return sig>0?(d.comments||0)/sig:0;
24342 });
24343 var maxDen=Math.max.apply(null,densities)||1;
24344 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">';
24345 LANG_D.forEach(function(d,i){
24346 var den=densities[i],bw=den/maxDen*BW;
24347 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24348 var pct=Math.round(den*100);
24349 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>';
24350 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"/>';
24351 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24352 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>';
24353 });
24354 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>';
24355 s+='</svg>';
24356 el.innerHTML=s;
24357 }
24358 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24359 renderDensity();
24360
24361 // ── Avg Lines per File: code / files per language ─────────────────────
24362 function renderAvgLinesInEl(el,shOvr){
24363 if(!el||!LANG_D||!LANG_D.length)return;
24364 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24365 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24366 var n=data.length||1;
24367 var LW=112,SH=shOvr||Math.max(180,n*28+26);
24368 var svgW=Math.max(320,el.offsetWidth||480);
24369 var BW=Math.max(120,svgW-LW-80);
24370 var topPad=4,botPad=26;
24371 var rowTotal=Math.floor((SH-topPad-botPad)/n);
24372 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24373 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24374 var maxAvg=Math.max.apply(null,avgs)||1;
24375 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">';
24376 data.forEach(function(d,i){
24377 var avg=avgs[i],bw=avg/maxAvg*BW;
24378 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24379 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>';
24380 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"/>';
24381 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24382 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>';
24383 });
24384 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>';
24385 s+='</svg>';
24386 el.innerHTML=s;
24387 }
24388 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24389 renderAvgLines();
24390
24391 // ── Repository Overview: overall row + per-submodule rows ────────────
24392 function renderSubmoduleInEl(el,key,sort,shOvr){
24393 if(!el)return;
24394 var overall={
24395 name:'Overall',
24396 code:{{ code_lines }},
24397 comment:{{ comment_lines }},
24398 blank:{{ blank_lines }},
24399 physical:{{ physical_lines }},
24400 files:{{ files_analyzed }},
24401 isOverall:true
24402 };
24403 var subs=SUB_D.slice();
24404 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24405 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24406 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24407 var data=[overall].concat(subs);
24408 var sepH=subs.length>0?14:0;
24409 var naturalH=data.length*32+sepH+16;
24410 var SH=shOvr||Math.max(100,naturalH);
24411 var svgW=Math.max(320,el.offsetWidth||480);
24412 var LW=116,BW=Math.max(200,svgW-LW-54);
24413 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24414 var OVERALL_COL='#6b7280';
24415 var topPad=4,botPad=8;
24416 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24417 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24418 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">';
24419 var yOff=topPad;
24420 data.forEach(function(d,i){
24421 var v=d[key]||0,bw=v/maxV*BW;
24422 var y=yOff+Math.floor((rowSlot-bH)/2);
24423 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24424 var label=d.name||d.path||'?';
24425 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>';
24426 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24427 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24428 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>';
24429 yOff+=rowSlot;
24430 if(d.isOverall&&subs.length>0){
24431 yOff+=sepH;
24432 }
24433 });
24434 s+='</svg>';
24435 el.innerHTML=s;
24436 }
24437 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24438 var subSel=document.getElementById('r-sub-metric');
24439 var sortSel=document.getElementById('r-sub-sort');
24440 renderSubmodule('code','desc');
24441 if(subSel){
24442 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24443 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24444 }
24445
24446 // Equalise heights within each chart row: if one chart in a grid row is taller
24447 // than its neighbour, re-render the shorter one at the taller height so bars fill
24448 // the available vertical space instead of leaving a gap.
24449 function syncRowHeights(){
24450 var avgEl=document.getElementById('r-avglines-chart');
24451 var subEl=document.getElementById('r-submodule-chart');
24452 if(avgEl&&subEl){
24453 var avgSvg=avgEl.querySelector('svg');
24454 var subSvg=subEl.querySelector('svg');
24455 if(avgSvg&&subSvg){
24456 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24457 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24458 var key=subSel?subSel.value||'code':'code';
24459 var sort=sortSel?sortSel.value:'desc';
24460 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24461 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24462 }
24463 }
24464 var semEl=document.getElementById('r-semantic-chart');
24465 var denEl=document.getElementById('r-density-chart');
24466 if(semEl&&denEl){
24467 var semSvg=semEl.querySelector('svg');
24468 var denSvg=denEl.querySelector('svg');
24469 if(semSvg&&denSvg){
24470 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24471 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24472 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24473 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24474 }
24475 }
24476 }
24477 syncRowHeights();
24478
24479 // Re-render all SVG charts when the window is resized so bars fill the card.
24480 var _rResizeTimer;
24481 window.addEventListener('resize',function(){
24482 clearTimeout(_rResizeTimer);
24483 _rResizeTimer=setTimeout(function(){
24484 var rcompBtn=document.querySelector('[data-rcomp].active');
24485 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24486 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24487 if(semSel)renderSemantic(semSel.value||'functions');
24488 renderDensity();
24489 renderAvgLines();
24490 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24491 syncRowHeights();
24492 },120);
24493 });
24494 })();
24495
24496 (function randomizeWatermarks() {
24497 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24498 if (!wms.length) return;
24499 var placed = [];
24500 function tooClose(top, left) {
24501 for (var i = 0; i < placed.length; i++) {
24502 var dt = Math.abs(placed[i][0] - top);
24503 var dl = Math.abs(placed[i][1] - left);
24504 if (dt < 20 && dl < 18) return true;
24505 }
24506 return false;
24507 }
24508 function pick(leftBand) {
24509 for (var attempt = 0; attempt < 50; attempt++) {
24510 var top = Math.random() * 85 + 5;
24511 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24512 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24513 }
24514 var top = Math.random() * 85 + 5;
24515 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24516 placed.push([top, left]);
24517 return [top, left];
24518 }
24519 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24520 var half = Math.floor(wms.length / 2);
24521 wms.forEach(function (img, i) {
24522 var pos = pick(i < half);
24523 var size = Math.floor(Math.random() * 100 + 160);
24524 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24525 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24526 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;
24527 });
24528 })();
24529
24530 (function spawnCodeParticles() {
24531 var container = document.getElementById('code-particles');
24532 if (!container) return;
24533 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'];
24534 for (var i = 0; i < 38; i++) {
24535 (function(idx) {
24536 var el = document.createElement('span');
24537 el.className = 'code-particle';
24538 el.textContent = snippets[idx % snippets.length];
24539 var left = Math.random() * 94 + 2;
24540 var top = Math.random() * 88 + 6;
24541 var dur = (Math.random() * 10 + 9).toFixed(1);
24542 var delay = (Math.random() * 18).toFixed(1);
24543 var rot = (Math.random() * 26 - 13).toFixed(1);
24544 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24545 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';
24546 container.appendChild(el);
24547 })(i);
24548 }
24549 })();
24550
24551 {% if pdf_generating %}
24552 // Poll for PDF readiness and swap the disabled button to a live link once done.
24553 (function() {
24554 var openBtn = document.getElementById('pdf-open-btn');
24555 var dlBtn = document.getElementById('pdf-download-btn');
24556 function checkPdf() {
24557 fetch('/api/runs/{{ run_id }}/pdf-status')
24558 .then(function(r) { return r.json(); })
24559 .then(function(d) {
24560 if (d.ready) {
24561 if (openBtn) {
24562 var a = document.createElement('a');
24563 a.className = 'button';
24564 a.id = 'pdf-open-btn';
24565 a.href = '/runs/pdf/{{ run_id }}';
24566 a.target = '_blank';
24567 a.rel = 'noopener';
24568 a.textContent = 'Open PDF';
24569 openBtn.replaceWith(a);
24570 }
24571 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24572 } else {
24573 setTimeout(checkPdf, 3000);
24574 }
24575 })
24576 .catch(function() { setTimeout(checkPdf, 5000); });
24577 }
24578 setTimeout(checkPdf, 3000);
24579 })();
24580 {% endif %}
24581
24582 })();
24583 </script>
24584 <script nonce="{{ csp_nonce }}">
24585 (function(){
24586 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'}];
24587 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);});}
24588 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24589 function init(){
24590 var btn=document.getElementById('settings-btn');if(!btn)return;
24591 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24592 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>';
24593 document.body.appendChild(m);
24594 var g=document.getElementById('scheme-grid');
24595 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);});
24596 var cl=document.getElementById('settings-close');
24597 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);
24598 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');});
24599 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24600 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24601 }
24602 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24603 }());
24604 </script>
24605 <footer class="site-footer">
24606 local code analysis - metrics, history and reports
24607 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24608 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24609 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24610 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24611 · <a href="/api-docs" rel="noopener">REST API</a>
24612 </footer>
24613 {% if confluence_configured %}
24614 <script nonce="{{ csp_nonce }}">
24615 (function() {
24616 var postBtn = document.getElementById('postConfluenceBtn');
24617 var copyBtn = document.getElementById('copyWikiBtn');
24618 var modal = document.getElementById('confluenceModal');
24619 if (!postBtn || !modal) return;
24620
24621 postBtn.addEventListener('click', function() {
24622 document.getElementById('confStatus').style.display = 'none';
24623 modal.style.display = 'flex';
24624 });
24625 document.getElementById('confCancelBtn').addEventListener('click', function() {
24626 modal.style.display = 'none';
24627 });
24628 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24629
24630 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24631 var btn = this;
24632 btn.disabled = true;
24633 var status = document.getElementById('confStatus');
24634 status.style.display = 'block';
24635 status.style.background = '#dbeafe';
24636 status.style.color = '#1e40af';
24637 status.textContent = 'Posting to Confluence\u2026';
24638 var resp = await fetch('/api/confluence/post', {
24639 method: 'POST',
24640 headers: { 'Content-Type': 'application/json' },
24641 body: JSON.stringify({
24642 run_id: '{{ run_id }}',
24643 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24644 report_url: document.getElementById('confReportUrl').value.trim() || null
24645 })
24646 });
24647 var data = await resp.json();
24648 if (data.ok) {
24649 status.style.background = '#dcfce7'; status.style.color = '#166534';
24650 status.textContent = 'Posted! Page ID: ' + data.page_id;
24651 } else {
24652 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24653 status.textContent = 'Error: ' + (data.error || 'Unknown error');
24654 }
24655 btn.disabled = false;
24656 });
24657
24658 if (copyBtn) {
24659 copyBtn.addEventListener('click', async function() {
24660 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24661 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24662 var text = await resp.text();
24663 try {
24664 await navigator.clipboard.writeText(text);
24665 var orig = copyBtn.textContent;
24666 copyBtn.textContent = 'Copied!';
24667 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24668 } catch(e) {
24669 alert('Clipboard write failed \u2014 check browser permissions.');
24670 }
24671 });
24672 }
24673 })();
24674 </script>
24675 {% endif %}
24676 <script nonce="{{ csp_nonce }}">
24677 (function() {
24678 var deleteBtn = document.getElementById('delete-run-btn');
24679 var modal = document.getElementById('delete-run-modal');
24680 var cancelBtn = document.getElementById('delete-run-cancel');
24681 var confirmBtn= document.getElementById('delete-run-confirm');
24682 if (!deleteBtn || !modal) return;
24683 deleteBtn.addEventListener('click', function() {
24684 document.getElementById('delete-run-status').style.display = 'none';
24685 modal.style.display = 'flex';
24686 });
24687 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24688 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24689 confirmBtn.addEventListener('click', async function() {
24690 confirmBtn.disabled = true;
24691 cancelBtn.disabled = true;
24692 var status = document.getElementById('delete-run-status');
24693 status.style.display = 'block';
24694 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24695 status.textContent = 'Deleting\u2026';
24696 try {
24697 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24698 if (resp.status === 204 || resp.ok) {
24699 status.style.background = '#dcfce7'; status.style.color = '#166534';
24700 status.textContent = 'Deleted. Redirecting\u2026';
24701 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24702 } else {
24703 var d = await resp.json().catch(function(){return {};});
24704 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24705 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24706 confirmBtn.disabled = false;
24707 cancelBtn.disabled = false;
24708 }
24709 } catch (e) {
24710 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24711 status.textContent = 'Network error: ' + String(e);
24712 confirmBtn.disabled = false;
24713 cancelBtn.disabled = false;
24714 }
24715 });
24716 })();
24717 </script>
24718 <script nonce="{{ csp_nonce }}">(function(){
24719 var bundleBtn = document.getElementById('download-bundle-btn');
24720 if (bundleBtn) {
24721 bundleBtn.addEventListener('click', function() {
24722 bundleBtn.disabled = true;
24723 var orig = bundleBtn.textContent;
24724 bundleBtn.textContent = 'Preparing\u2026';
24725 fetch('/api/runs/{{ run_id }}/bundle')
24726 .then(function(r) {
24727 if (!r.ok) throw new Error('HTTP ' + r.status);
24728 return r.blob();
24729 })
24730 .then(function(blob) {
24731 var url = URL.createObjectURL(blob);
24732 var a = document.createElement('a');
24733 a.href = url;
24734 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24735 document.body.appendChild(a);
24736 a.click();
24737 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24738 bundleBtn.disabled = false;
24739 bundleBtn.textContent = orig;
24740 })
24741 .catch(function(e) {
24742 bundleBtn.disabled = false;
24743 bundleBtn.textContent = orig;
24744 alert('Bundle download failed: ' + String(e));
24745 });
24746 });
24747 }
24748 })();</script>
24749 <script nonce="{{ csp_nonce }}">(function(){
24750 var dot=document.getElementById('status-dot');
24751 var pingEl=document.getElementById('server-ping-ms');
24752 var tipEl=document.getElementById('server-tip-ping');
24753 var fm=document.getElementById('footer-mode');
24754 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)';}}
24755 function doPing(){
24756 var t0=performance.now();
24757 fetch('/healthz',{cache:'no-store'})
24758 .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);})
24759 .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)';}});
24760 }
24761 doPing();
24762 setInterval(doPing,5000);
24763 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');}
24764 })();</script>
24765 <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>
24766 {% if let Some(banner) = report_header_footer %}
24767 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24768 {% endif %}
24769</body>
24770</html>
24771"##,
24772 ext = "html"
24773)]
24774#[allow(clippy::struct_excessive_bools)]
24776struct ResultTemplate {
24777 version: &'static str,
24778 report_title: String,
24779 project_path: String,
24780 output_dir: String,
24781 run_id: String,
24782 files_analyzed: u64,
24783 files_skipped: u64,
24784 physical_lines: u64,
24785 code_lines: u64,
24786 comment_lines: u64,
24787 blank_lines: u64,
24788 mixed_lines: u64,
24789 functions: u64,
24790 classes: u64,
24791 variables: u64,
24792 imports: u64,
24793 html_url: Option<String>,
24794 pdf_url: Option<String>,
24795 json_url: Option<String>,
24796 html_download_url: Option<String>,
24797 pdf_download_url: Option<String>,
24798 json_download_url: Option<String>,
24799 html_path: Option<String>,
24800 json_path: Option<String>,
24801 prev_run_id: Option<String>,
24802 prev_run_timestamp: Option<String>,
24803 prev_run_code_lines: Option<u64>,
24804 prev_fa_str: String,
24806 prev_fs_str: String,
24807 prev_pl_str: String,
24808 prev_cl_str: String,
24809 prev_cml_str: String,
24810 prev_bl_str: String,
24811 delta_fa_str: String,
24813 delta_fa_class: String,
24814 delta_fs_str: String,
24815 delta_fs_class: String,
24816 delta_pl_str: String,
24817 delta_pl_class: String,
24818 delta_cl_str: String,
24819 delta_cl_class: String,
24820 delta_cml_str: String,
24821 delta_cml_class: String,
24822 delta_bl_str: String,
24823 delta_bl_class: String,
24824 delta_lines_added: Option<i64>,
24826 delta_lines_removed: Option<i64>,
24827 delta_lines_net_str: String,
24828 delta_lines_net_class: String,
24829 delta_files_added: Option<usize>,
24830 delta_files_removed: Option<usize>,
24831 delta_files_modified: Option<usize>,
24832 delta_files_unchanged: Option<usize>,
24833 delta_unmodified_lines: Option<u64>,
24834 git_branch: Option<String>,
24836 git_branch_url: Option<String>,
24837 git_commit: Option<String>,
24838 git_commit_long: Option<String>,
24839 git_author: Option<String>,
24840 git_commit_url: Option<String>,
24841 scan_performed_by: String,
24843 scan_time_display: String,
24844 os_display: String,
24845 test_count: u64,
24846 test_assertion_count: u64,
24848 prev_scan_count: usize,
24850 current_scan_number: usize,
24851 submodule_rows: Vec<SubmoduleRow>,
24853 scan_config_url: String,
24854 lang_chart_json: String,
24855 #[allow(dead_code)]
24857 scatter_chart_json: String,
24858 #[allow(dead_code)]
24859 semantic_chart_json: String,
24860 #[allow(dead_code)]
24861 submodule_chart_json: String,
24862 #[allow(dead_code)]
24863 has_submodule_data: bool,
24864 #[allow(dead_code)]
24865 has_semantic_data: bool,
24866 pdf_generating: bool,
24867 csp_nonce: String,
24868 confluence_configured: bool,
24870 server_mode: bool,
24871 report_header_footer: Option<String>,
24873 run_id_short: String,
24874 #[allow(dead_code)]
24876 is_offline: bool,
24877 cyclomatic_complexity: u64,
24879 lsloc: Option<u64>,
24881 uloc: u64,
24883 dryness_pct_str: String,
24885 duplicate_group_count: usize,
24887 has_cocomo: bool,
24889 cocomo_effort_str: String,
24891 cocomo_duration_str: String,
24893 cocomo_staff_str: String,
24895 cocomo_ksloc_str: String,
24897 cocomo_mode_label: String,
24899 cocomo_mode_tooltip: String,
24901 complexity_alert: u32,
24903 has_coverage_data: bool,
24905 cov_line_pct: String,
24907 cov_fn_pct: String,
24909 cov_branch_pct: String,
24911 cov_lines_summary: String,
24913}
24914
24915#[derive(Template)]
24916#[template(
24917 source = r##"
24918<!doctype html>
24919<html lang="en">
24920<head>
24921 <meta charset="utf-8">
24922 <meta name="viewport" content="width=device-width, initial-scale=1">
24923 <title>OxideSLOC | Analyzing…</title>
24924 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24925 <style nonce="{{ csp_nonce }}">
24926 :root {
24927 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24928 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24929 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
24930 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24931 }
24932 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24933 *{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;}
24934 .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);}
24935 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24936 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24937 .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));}
24938 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24939 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24940 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
24941 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24942 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24943 @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; } }
24944 .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;}
24945 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24946 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24947 .page-body{padding:32px 24px 36px;}
24948 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
24949 .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;}
24950 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
24951 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
24952 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
24953 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
24954 .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;}
24955 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
24956 .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;}
24957 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
24958 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
24959 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
24960 .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;}
24961 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
24962 .hidden{display:none!important;}
24963 .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;}
24964 .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;}
24965 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
24966 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
24967 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
24968 .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);}
24969 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
24970 .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;}
24971 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
24972 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24973 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24974 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
24975 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24976 .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;}
24977 @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));}}
24978 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24979 .site-footer a{color:var(--muted);}
24980 .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;}
24981 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
24982 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
24983 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
24984 </style>
24985</head>
24986<body>
24987 <div class="background-watermarks" aria-hidden="true">
24988 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24989 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24990 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24991 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24992 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24993 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24994 </div>
24995 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24996 <nav class="top-nav">
24997 <div class="top-nav-inner">
24998 <a href="/" class="brand">
24999 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25000 <div class="brand-copy">
25001 <h1 class="brand-title">OxideSLOC</h1>
25002 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25003 </div>
25004 </a>
25005 <div class="nav-right">
25006 <a class="nav-pill" href="/">Home</a>
25007 <div class="nav-dropdown">
25008 <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>
25009 <div class="nav-dropdown-menu">
25010 <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>
25011 </div>
25012 </div>
25013 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25014 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25015 <div class="nav-dropdown">
25016 <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>
25017 <div class="nav-dropdown-menu">
25018 <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>
25019 </div>
25020 </div>
25021 <div class="server-status-wrap" id="server-status-wrap">
25022 <div class="nav-pill server-online-pill" id="server-status-pill">
25023 <span class="status-dot" id="status-dot"></span>
25024 <span id="server-status-label">Server</span>
25025 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25026 </div>
25027 <div class="server-status-tip">
25028 OxideSLOC is running — accessible on your network.
25029 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25030 </div>
25031 </div>
25032 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25033 <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>
25034 </button>
25035 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25036 <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>
25037 <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>
25038 </button>
25039 </div>
25040 </div>
25041 </nav>
25042 <div class="page-body">
25043 <div class="wait-panel">
25044 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25045 <h2 class="wait-title">Analyzing your project…</h2>
25046 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25047 <div class="path-block">{{ project_path }}</div>
25048 <div class="metrics-row">
25049 <div class="metric-card">
25050 <div class="metric-label">Elapsed</div>
25051 <div class="metric-value" id="elapsed">0s</div>
25052 </div>
25053 <div class="metric-card">
25054 <div class="metric-label">Phase</div>
25055 <div class="metric-value" id="phase">Starting</div>
25056 </div>
25057 <div class="metric-card hidden" id="files-card">
25058 <div class="metric-label">Files</div>
25059 <div class="metric-value" id="files-progress">0</div>
25060 </div>
25061 </div>
25062 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25063 <div class="warn-slow hidden" id="warn-slow">
25064 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.
25065 </div>
25066 <div class="err-panel hidden" id="err-panel">
25067 <strong>Analysis failed</strong>
25068 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25069 </div>
25070 <div class="actions hidden" id="actions">
25071 <a href="/scan" class="btn-primary">Try Again</a>
25072 <a href="/view-reports" class="btn-outline">View Reports</a>
25073 </div>
25074 </div>
25075 </div>
25076 <script nonce="{{ csp_nonce }}">
25077 (function() {
25078 var WAIT_ID = {{ wait_id_json|safe }};
25079 var startTime = Date.now();
25080 var pollInterval = 1500;
25081 var retries = 0;
25082 var maxRetries = 5;
25083 var warnShown = false;
25084
25085 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();}
25086
25087 function elapsed() {
25088 return Math.floor((Date.now() - startTime) / 1000);
25089 }
25090
25091 function updateElapsed() {
25092 var s = elapsed();
25093 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25094 }
25095
25096 function setPhase(txt) {
25097 document.getElementById('phase').textContent = txt;
25098 }
25099
25100 var elapsedTimer = setInterval(updateElapsed, 1000);
25101
25102 function poll() {
25103 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25104 .then(function(r) {
25105 if (!r.ok) throw new Error('HTTP ' + r.status);
25106 return r.json();
25107 })
25108 .then(function(data) {
25109 retries = 0;
25110 if (data.state === 'complete') {
25111 clearInterval(elapsedTimer);
25112 setPhase('Done');
25113 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25114 } else if (data.state === 'failed') {
25115 clearInterval(elapsedTimer);
25116 setPhase('Failed');
25117 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25118 document.getElementById('err-panel').classList.remove('hidden');
25119 document.getElementById('actions').classList.remove('hidden');
25120 } else {
25121 // still running
25122 var s = elapsed();
25123 if (s > 90 && !warnShown) {
25124 warnShown = true;
25125 document.getElementById('warn-slow').classList.remove('hidden');
25126 }
25127 setPhase(data.phase || 'Running');
25128 var fd = data.files_done || 0, ft = data.files_total || 0;
25129 if (ft > 0) {
25130 var card = document.getElementById('files-card');
25131 if (card) card.classList.remove('hidden');
25132 var fp = document.getElementById('files-progress');
25133 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25134 }
25135 setTimeout(poll, pollInterval);
25136 }
25137 })
25138 .catch(function(err) {
25139 retries++;
25140 if (retries >= maxRetries) {
25141 clearInterval(elapsedTimer);
25142 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25143 document.getElementById('err-panel').classList.remove('hidden');
25144 document.getElementById('actions').classList.remove('hidden');
25145 } else {
25146 // exponential back-off capped at 8s
25147 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25148 }
25149 });
25150 }
25151
25152 setTimeout(poll, pollInterval);
25153
25154 // If the browser restores this page from bfcache (Back after viewing results),
25155 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25156 window.addEventListener("pageshow", function(e) {
25157 if (e.persisted) { setTimeout(poll, 200); }
25158 });
25159 })();
25160 </script>
25161 <footer class="site-footer">
25162 local code analysis - metrics, history and reports
25163 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25164 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25165 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25166 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25167 · <a href="/api-docs" rel="noopener">REST API</a>
25168 </footer>
25169 <script nonce="{{ csp_nonce }}">
25170 (function(){
25171 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25172 if(s==="dark")b.classList.add("dark-theme");
25173 var tt=document.getElementById("theme-toggle");
25174 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25175 })();
25176 (function spawnCodeParticles(){
25177 var c=document.getElementById('code-particles');if(!c)return;
25178 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'];
25179 for(var i=0;i<32;i++){(function(idx){
25180 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25181 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25182 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25183 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25184 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25185 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25186 c.appendChild(el);
25187 })(i);}
25188 })();
25189 (function randomizeWatermarks(){
25190 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25191 var placed=[];
25192 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;}
25193 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];}
25194 var half=Math.floor(wms.length/2);
25195 wms.forEach(function(img,i){
25196 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25197 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25198 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25199 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25200 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25201 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25202 });
25203 })();
25204 </script>
25205 <script nonce="{{ csp_nonce }}">
25206 (function(){
25207 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'}];
25208 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);});}
25209 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25210 function init(){
25211 var btn=document.getElementById('settings-btn');if(!btn)return;
25212 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25213 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>';
25214 document.body.appendChild(m);
25215 var g=document.getElementById('scheme-grid');
25216 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);});
25217 var cl=document.getElementById('settings-close');
25218 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);
25219 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');});
25220 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25221 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25222 }
25223 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25224 }());
25225 </script>
25226 <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]';
25227 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;}
25228 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>
25229</body>
25230</html>
25231"##,
25232 ext = "html"
25233)]
25234struct ScanWaitTemplate {
25235 version: &'static str,
25236 wait_id_json: String,
25237 project_path: String,
25238 csp_nonce: String,
25239}
25240
25241#[derive(Template)]
25242#[template(
25243 source = r##"
25244<!doctype html>
25245<html lang="en">
25246<head>
25247 <meta charset="utf-8">
25248 <meta name="viewport" content="width=device-width, initial-scale=1">
25249 <title>OxideSLOC | Error</title>
25250 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25251 <style nonce="{{ csp_nonce }}">
25252 :root {
25253 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25254 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25255 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25256 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25257 }
25258 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25259 *{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;}
25260 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25261 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25262 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25263 .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);}
25264 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25265 .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));}
25266 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25267 .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;}
25268 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25269 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25270 @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; } }
25271 .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;}
25272 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25273 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25274 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25275 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25276 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25277 .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;}
25278 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25279 .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);}
25280 .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;}
25281 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25282 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25283 .settings-modal-body{padding:14px 16px 16px;}
25284 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25285 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25286 .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;}
25287 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25288 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25289 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25290 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25291 .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;}
25292 .tz-select:focus{border-color:var(--oxide);}
25293 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25294 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25295 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25296 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25297 .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;}
25298 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25299 .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);}
25300 .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;}
25301 .btn-secondary:hover{background:var(--line);}
25302 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25303 .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;}
25304 .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;}
25305 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25306 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25307 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25308 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25309 .bug-report-panel.open{display:flex;}
25310 .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;}
25311 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25312 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25313 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25314 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25315 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25316 .br-network-badge.online .br-net-dot{background:#2a6846;}
25317 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25318 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25319 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25320 .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;}
25321 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25322 .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;}
25323 .btn-sm:hover{background:var(--line);}
25324 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25325 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25326 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25327 .bug-report-hint a:hover{text-decoration:underline;}
25328 .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;}
25329 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25330 .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;}
25331 .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;}
25332 .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;}
25333 @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));}}
25334 .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;}
25335 </style>
25336</head>
25337<body>
25338 <div class="background-watermarks" aria-hidden="true">
25339 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25340 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25341 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25342 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25343 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25344 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25345 </div>
25346 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25347 <div class="top-nav">
25348 <div class="top-nav-inner">
25349 <a class="brand" href="/">
25350 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25351 <div class="brand-copy">
25352 <div class="brand-title">OxideSLOC</div>
25353 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25354 </div>
25355 </a>
25356 <div class="nav-right">
25357 <a class="nav-pill" href="/">Home</a>
25358 <div class="nav-dropdown">
25359 <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>
25360 <div class="nav-dropdown-menu">
25361 <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>
25362 </div>
25363 </div>
25364 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25365 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25366 <div class="nav-dropdown">
25367 <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>
25368 <div class="nav-dropdown-menu">
25369 <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>
25370 </div>
25371 </div>
25372 <div class="server-status-wrap" id="server-status-wrap">
25373 <div class="nav-pill server-online-pill" id="server-status-pill">
25374 <span class="status-dot" id="status-dot"></span>
25375 <span id="server-status-label">Server</span>
25376 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25377 </div>
25378 <div class="server-status-tip">
25379 OxideSLOC is running — accessible on your network.
25380 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25381 </div>
25382 </div>
25383 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25384 <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>
25385 </button>
25386 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25387 <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>
25388 <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>
25389 </button>
25390 </div>
25391 </div>
25392 </div>
25393
25394 <div class="page">
25395 <div class="panel">
25396 <h1>Error</h1>
25397 <div class="error-box" id="error-msg-text">{{ message }}</div>
25398 <div id="br-meta" hidden
25399 data-version="{{ version }}"
25400 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25401 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25402 <div class="actions">
25403 <a class="btn-primary" href="/scan">Back to setup</a>
25404 {% if let Some(report_url) = last_report_url %}
25405 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25406 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25407 {% else %}
25408 <a class="btn-secondary" href="/view-reports">View Reports</a>
25409 {% endif %}
25410 </div>
25411 <div class="bug-report-section" id="bug-report-section">
25412 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25413 <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>
25414 Generate Bug Report
25415 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25416 </button>
25417 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25418 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
25419 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
25420 <div class="bug-report-btns">
25421 <button type="button" class="btn-sm" id="bug-report-copy">
25422 <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>
25423 Copy to clipboard
25424 </button>
25425 <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;">
25426 <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>
25427 Open GitHub Issue
25428 </a>
25429 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25430 <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>
25431 Save as file
25432 </button>
25433 </div>
25434 <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>
25435 <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>
25436 </div>
25437 </div>
25438 </div>
25439 </div>
25440 <footer class="site-footer">
25441 oxide-sloc v{{ version }} — local code metrics workbench ·
25442 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25443 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25444 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25445 · <a href="/api-docs" rel="noopener">REST API</a>
25446 </footer>
25447 <script nonce="{{ csp_nonce }}">(function(){
25448 var meta=document.getElementById('br-meta');
25449 var pre=document.getElementById('bug-report-pre');
25450 var copyBtn=document.getElementById('bug-report-copy');
25451 var trigger=document.getElementById('bug-report-trigger');
25452 var panel=document.getElementById('bug-report-panel');
25453 var networkBadge=document.getElementById('br-network-badge');
25454 var networkLabel=document.getElementById('br-network-label');
25455 var ghLink=document.getElementById('bug-report-github-link');
25456 var saveBtn=document.getElementById('bug-report-save');
25457 var hintOnline=document.getElementById('br-hint-online');
25458 var hintOffline=document.getElementById('br-hint-offline');
25459 if(!meta||!pre)return;
25460 var ver=meta.getAttribute('data-version')||'';
25461 var runId=meta.getAttribute('data-run-id')||'';
25462 var code=meta.getAttribute('data-error-code')||'';
25463 var msgEl=document.getElementById('error-msg-text');
25464 var msg=msgEl?msgEl.textContent.trim():'';
25465 function getBrowser(){
25466 var ua=navigator.userAgent;
25467 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25468 if(!m)return 'Unknown browser';
25469 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25470 return n+' '+m[2];
25471 }
25472 var lines=['oxide-sloc Bug Report','==============================',''];
25473 lines.push('App version: v'+ver);
25474 if(code)lines.push('HTTP status: '+code);
25475 if(runId)lines.push('Run ID: '+runId);
25476 lines.push('Page: '+window.location.pathname+(window.location.search||''));
25477 lines.push('Timestamp: '+new Date().toISOString());
25478 lines.push('Browser: '+getBrowser());
25479 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
25480 lines.push('');
25481 lines.push('Error message:');
25482 lines.push(msg);
25483 lines.push('');
25484 lines.push('Steps to reproduce:');
25485 lines.push(' 1. ');
25486 lines.push('');
25487 lines.push('Expected behavior:');
25488 lines.push(' ');
25489 pre.textContent=lines.join('\n');
25490 function applyNetwork(online){
25491 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25492 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25493 if(ghLink){
25494 if(online){
25495 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25496 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25497 }
25498 ghLink.style.display=online?'inline-flex':'none';
25499 }
25500 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25501 if(hintOnline)hintOnline.style.display=online?'block':'none';
25502 if(hintOffline)hintOffline.style.display=online?'none':'block';
25503 }
25504 applyNetwork(navigator.onLine);
25505 var probed=false;
25506 function probeNetwork(){
25507 if(probed)return;probed=true;
25508 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25509 var probeIdx=0;
25510 function tryNext(){
25511 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25512 var u=probeUrls[probeIdx++];
25513 var c2=new AbortController();
25514 var t2=setTimeout(function(){c2.abort();},4000);
25515 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25516 .then(function(){clearTimeout(t2);applyNetwork(true);})
25517 .catch(function(){clearTimeout(t2);tryNext();});
25518 }
25519 tryNext();
25520 }
25521 if(trigger&&panel){
25522 trigger.addEventListener('click',function(){
25523 var open=panel.classList.toggle('open');
25524 trigger.classList.toggle('open',open);
25525 trigger.setAttribute('aria-expanded',open?'true':'false');
25526 if(open)probeNetwork();
25527 });
25528 }
25529 if(copyBtn){
25530 copyBtn.addEventListener('click',function(){
25531 var txt=pre.textContent;
25532 if(navigator.clipboard&&navigator.clipboard.writeText){
25533 navigator.clipboard.writeText(txt).then(function(){
25534 copyBtn.textContent='\u2713 Copied!';
25535 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);
25536 });
25537 }else{
25538 var ta=document.createElement('textarea');
25539 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25540 document.body.appendChild(ta);ta.select();
25541 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25542 document.body.removeChild(ta);
25543 }
25544 });
25545 }
25546 if(saveBtn){
25547 saveBtn.addEventListener('click',function(){
25548 var txt=pre.textContent;
25549 var blob=new Blob([txt],{type:'text/plain'});
25550 var url=URL.createObjectURL(blob);
25551 var a=document.createElement('a');
25552 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25553 document.body.appendChild(a);a.click();
25554 document.body.removeChild(a);URL.revokeObjectURL(url);
25555 });
25556 }
25557 })();</script>
25558 <script nonce="{{ csp_nonce }}">
25559 (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");});})();
25560 (function spawnCodeParticles() {
25561 var container = document.getElementById('code-particles');
25562 if (!container) return;
25563 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'];
25564 for (var i = 0; i < 38; i++) {
25565 (function(idx) {
25566 var el = document.createElement('span');
25567 el.className = 'code-particle';
25568 el.textContent = snippets[idx % snippets.length];
25569 var left = Math.random() * 94 + 2;
25570 var top = Math.random() * 88 + 6;
25571 var dur = (Math.random() * 10 + 9).toFixed(1);
25572 var delay = (Math.random() * 18).toFixed(1);
25573 var rot = (Math.random() * 26 - 13).toFixed(1);
25574 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25575 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';
25576 container.appendChild(el);
25577 })(i);
25578 }
25579 })();
25580 (function randomizeWatermarks() {
25581 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25582 var placed = [];
25583 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; }
25584 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]; }
25585 var half = Math.floor(wms.length/2);
25586 wms.forEach(function(img, i) {
25587 var pos = pick(i < half);
25588 var w = Math.floor(Math.random()*60+80);
25589 var rot = (Math.random()*40-20).toFixed(1);
25590 var op = (Math.random()*0.08+0.05).toFixed(2);
25591 var animDur = (Math.random()*6+5).toFixed(1);
25592 var animDelay = (Math.random()*10).toFixed(1);
25593 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';
25594 });
25595 })();
25596 </script>
25597 <script nonce="{{ csp_nonce }}">
25598 (function(){
25599 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'}];
25600 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);});}
25601 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25602 function init(){
25603 var btn=document.getElementById('settings-btn');if(!btn)return;
25604 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25605 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>';
25606 document.body.appendChild(m);
25607 var g=document.getElementById('scheme-grid');
25608 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);});
25609 var cl=document.getElementById('settings-close');
25610 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);
25611 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');});
25612 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25613 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25614 }
25615 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25616 }());
25617 </script>
25618 <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]';
25619 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;}
25620 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>
25621</body>
25622</html>
25623"##,
25624 ext = "html"
25625)]
25626struct ErrorTemplate {
25627 message: String,
25628 last_report_url: Option<String>,
25630 last_report_label: Option<String>,
25632 run_id: Option<String>,
25634 error_code: Option<u16>,
25636 csp_nonce: String,
25637 version: &'static str,
25638}
25639
25640#[derive(Template)]
25643#[template(
25644 source = r##"
25645<!doctype html>
25646<html lang="en">
25647<head>
25648 <meta charset="utf-8">
25649 <meta name="viewport" content="width=device-width, initial-scale=1">
25650 <title>OxideSLOC | Locate Report</title>
25651 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25652 <style nonce="{{ csp_nonce }}">
25653 :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);}
25654 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25655 *{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;}
25656 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25657 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25658 .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);}
25659 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25660 .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));}
25661 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25662 .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;}
25663 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25664 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25665 @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;}}
25666 .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;}
25667 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25668 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25669 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25670 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25671 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25672 .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;}
25673 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25674 .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);}
25675 .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;}
25676 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25677 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25678 .settings-modal-body{padding:14px 16px 16px;}
25679 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25680 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25681 .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;}
25682 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25683 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25684 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25685 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25686 .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;}
25687 .tz-select:focus{border-color:var(--oxide);}
25688 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25689 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25690 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25691 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25692 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25693 .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;}
25694 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25695 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25696 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25697 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25698 .locate-row{display:flex;gap:8px;align-items:stretch;}
25699 .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;}
25700 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25701 body.dark-theme .locate-input{background:var(--surface-2);}
25702 .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;}
25703 .warning-banner.show{display:flex;}
25704 .warning-banner svg{flex:0 0 auto;}
25705 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25706 .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;}
25707 .error-inline.show{display:flex;}
25708 .error-inline svg{flex:0 0 auto;margin-top:2px;}
25709 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25710 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25711 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25712 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25713 .err-kv-p{margin:0 0 4px;}
25714 .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;}
25715 .success-inline.show{display:flex;}
25716 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25717 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25718 .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;}
25719 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25720 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25721 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25722 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25723 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25724 .fh-row:last-child{border-bottom:none;}
25725 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25726 .fh-dir{font-weight:800;color:var(--text);}
25727 .fh-hl{color:var(--oxide);font-weight:700;}
25728 .fh-muted{color:var(--muted);}
25729 .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;}
25730 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25731 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25732 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25733 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25734 .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;}
25735 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25736 .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;}
25737 .btn-secondary:hover{background:var(--line);}
25738 .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;}
25739 .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;}
25740 .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;}
25741 @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));}}
25742 .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;}
25743 .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;}
25744 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25745 </style>
25746</head>
25747<body>
25748 <div class="background-watermarks" aria-hidden="true">
25749 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25750 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25751 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25752 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25753 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25754 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25755 </div>
25756 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25757 <div class="top-nav">
25758 <div class="top-nav-inner">
25759 <a class="brand" href="/">
25760 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25761 <div class="brand-copy">
25762 <div class="brand-title">OxideSLOC</div>
25763 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25764 </div>
25765 </a>
25766 <div class="nav-right">
25767 <a class="nav-pill" href="/">Home</a>
25768 <div class="nav-dropdown">
25769 <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>
25770 <div class="nav-dropdown-menu">
25771 <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>
25772 </div>
25773 </div>
25774 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25775 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25776 <div class="nav-dropdown">
25777 <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>
25778 <div class="nav-dropdown-menu">
25779 <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>
25780 </div>
25781 </div>
25782 <div class="server-status-wrap" id="server-status-wrap">
25783 <div class="nav-pill server-online-pill" id="server-status-pill">
25784 <span class="status-dot" id="status-dot"></span>
25785 <span id="server-status-label">Server</span>
25786 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25787 </div>
25788 <div class="server-status-tip">
25789 OxideSLOC is running — accessible on your network.
25790 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25791 </div>
25792 </div>
25793 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25794 <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>
25795 </button>
25796 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25797 <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>
25798 <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>
25799 </button>
25800 </div>
25801 </div>
25802 </div>
25803
25804 <div class="page">
25805 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25806 <div class="panel">
25807 <h1>Report File Not Found</h1>
25808 <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>
25809 <div class="field-label">Missing file</div>
25810 <div class="filename-chip">
25811 <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>
25812 {{ expected_filename }}
25813 </div>
25814 <div class="locate-section">
25815 <h2>Locate Scan Output Folder</h2>
25816 <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>
25817 <p>OxideSLOC will find the correct files inside automatically.</p>
25818 <div class="locate-row">
25819 <input type="text" id="locate-file-input"
25820 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25821 class="locate-input" autocomplete="off" spellcheck="false">
25822 {% if !server_mode %}
25823 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
25824 {% endif %}
25825 </div>
25826 <div class="warning-banner" id="filename-warning">
25827 <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>
25828 <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>
25829 </div>
25830 <div class="error-inline" id="locate-error">
25831 <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>
25832 <span id="locate-error-text"></span>
25833 </div>
25834 <div class="success-inline" id="locate-success">
25835 <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>
25836 <span>Scan restored — loading report…</span>
25837 </div>
25838 <div class="btn-row">
25839 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25840 <a class="btn-secondary" href="/view-reports">View Reports</a>
25841 </div>
25842 <div class="folder-hint-shell">
25843 <div class="folder-hint-hdr">
25844 <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>
25845 Expected Folder Structure — Select the Top-Level Folder
25846 </div>
25847 <div class="folder-hint-body">
25848 <div class="fh-row">
25849 <span class="fh-tog">►</span>
25850 <span class="fh-dir">project_20260601-0029-…/</span>
25851 <span class="fh-badge">← select this</span>
25852 </div>
25853 <div class="fh-row fh-i1">
25854 <span class="fh-tog">►</span>
25855 <span class="fh-dir">html/</span>
25856 </div>
25857 <div class="fh-row fh-i2">
25858 <span class="fh-bul">•</span>
25859 <span class="fh-hl">{{ expected_filename }}</span>
25860 </div>
25861 <div class="fh-row fh-i1">
25862 <span class="fh-tog">►</span>
25863 <span class="fh-dir">json/</span>
25864 </div>
25865 <div class="fh-row fh-i2">
25866 <span class="fh-bul">•</span>
25867 <span class="fh-muted">result_*.json</span>
25868 </div>
25869 <div class="fh-row fh-i1">
25870 <span class="fh-tog">►</span>
25871 <span class="fh-dir">pdf/</span>
25872 </div>
25873 <div class="fh-row fh-i2">
25874 <span class="fh-bul">•</span>
25875 <span class="fh-muted">report_*.pdf</span>
25876 </div>
25877 <div class="fh-row fh-i1">
25878 <span class="fh-tog">►</span>
25879 <span class="fh-dir">excel/</span>
25880 </div>
25881 <div class="fh-row fh-i2">
25882 <span class="fh-bul">•</span>
25883 <span class="fh-muted">report_*.csv report_*.xlsx</span>
25884 </div>
25885 </div>
25886 </div>
25887 </div>
25888 </div>
25889 </div>
25890 <footer class="site-footer">
25891 oxide-sloc v{{ version }} — local code metrics workbench ·
25892 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25893 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25894 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25895 · <a href="/api-docs" rel="noopener">REST API</a>
25896 </footer>
25897 <script nonce="{{ csp_nonce }}">(function(){
25898 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25899 if(s==="dark")b.classList.add("dark-theme");
25900 document.getElementById("theme-toggle").addEventListener("click",function(){
25901 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
25902 });
25903 })();</script>
25904 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
25905 var c=document.getElementById('code-particles');if(!c)return;
25906 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'];
25907 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);}
25908 })();
25909 (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>
25910 <script nonce="{{ csp_nonce }}">(function(){
25911 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'}];
25912 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);});}
25913 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25914 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');});}
25915 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25916 }());</script>
25917 <script nonce="{{ csp_nonce }}">(function(){
25918 var meta=document.getElementById('locate-meta');
25919 var inp=document.getElementById('locate-file-input');
25920 var browseBtn=document.getElementById('browse-locate-btn');
25921 var submitBtn=document.getElementById('locate-submit-btn');
25922 var warning=document.getElementById('filename-warning');
25923 var errBox=document.getElementById('locate-error');
25924 var errText=document.getElementById('locate-error-text');
25925 var okBox=document.getElementById('locate-success');
25926 var expected=meta?meta.getAttribute('data-expected'):'';
25927 var runId=meta?meta.getAttribute('data-run-id'):'';
25928 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
25929 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
25930 function showErr(msg){
25931 if(errText){
25932 errText.innerHTML='';
25933 var lines=msg.split('\n');
25934 var hasPairs=lines.some(function(l){return / : /.test(l);});
25935 if(!hasPairs){errText.textContent=msg;}
25936 else{
25937 var frag=document.createDocumentFragment();var tbl=null;
25938 lines.forEach(function(line){
25939 var m=line.match(/^(.*?) : (.*)$/);
25940 if(m){
25941 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
25942 var tr=document.createElement('tr');
25943 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
25944 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
25945 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
25946 } else {
25947 tbl=null;
25948 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
25949 }
25950 });
25951 errText.appendChild(frag);
25952 }
25953 }
25954 if(errBox)errBox.classList.add('show');
25955 if(okBox)okBox.classList.remove('show');
25956 }
25957 function clearErr(){
25958 if(errBox)errBox.classList.remove('show');
25959 if(okBox)okBox.classList.remove('show');
25960 }
25961 function validate(){
25962 var val=inp?inp.value.trim():'';
25963 clearErr();
25964 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
25965 if(submitBtn)submitBtn.disabled=false;
25966 if(warning){
25967 var name=basename(val);
25968 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
25969 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
25970 else warning.classList.remove('show');
25971 }
25972 }
25973 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
25974 if(browseBtn){
25975 browseBtn.addEventListener('click',function(){
25976 browseBtn.disabled=true;browseBtn.textContent='...';
25977 fetch('/pick-directory')
25978 .then(function(r){return r.ok?r.json():{cancelled:true};})
25979 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
25980 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
25981 });
25982 }
25983 if(submitBtn){
25984 submitBtn.addEventListener('click',function(){
25985 var folder=inp?inp.value.trim():'';
25986 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
25987 clearErr();
25988 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
25989 var body=new URLSearchParams();
25990 body.set('file_path',folder);
25991 body.set('redirect_url',redirectUrl);
25992 body.set('expected_run_id',runId);
25993 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
25994 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
25995 .then(function(d){
25996 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25997 if(d&&d.ok){
25998 if(okBox)okBox.classList.add('show');
25999 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26000 } else {
26001 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26002 }
26003 })
26004 .catch(function(e){
26005 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26006 showErr('Network error: '+String(e));
26007 });
26008 });
26009 }
26010 })();</script>
26011 <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>
26012</body>
26013</html>
26014"##,
26015 ext = "html"
26016)]
26017struct LocateFileTemplate {
26018 run_id: String,
26019 artifact_type: String,
26020 expected_filename: String,
26021 server_mode: bool,
26022 csp_nonce: String,
26023 version: &'static str,
26024}
26025
26026#[derive(Template)]
26029#[template(
26030 source = r##"
26031<!doctype html>
26032<html lang="en">
26033<head>
26034 <meta charset="utf-8">
26035 <meta name="viewport" content="width=device-width, initial-scale=1">
26036 <title>OxideSLOC | Locate Scan Files</title>
26037 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26038 <style nonce="{{ csp_nonce }}">
26039 :root {
26040 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26041 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26042 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26043 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26044 }
26045 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26046 *{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;}
26047 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26048 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26049 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26050 .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);}
26051 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26052 .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));}
26053 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26054 .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;}
26055 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26056 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26057 @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;}}
26058 .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;}
26059 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26060 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26061 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26062 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26063 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26064 .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;}
26065 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26066 .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);}
26067 .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;}
26068 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26069 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26070 .settings-modal-body{padding:14px 16px 16px;}
26071 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26072 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26073 .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;}
26074 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26075 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26076 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26077 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26078 .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;}
26079 .tz-select:focus{border-color:var(--oxide);}
26080 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26081 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26082 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26083 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26084 .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;}
26085 .error-box.hidden{display:none;}
26086 .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;}
26087 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26088 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26089 .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;}
26090 .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;}
26091 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26092 .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;}
26093 .btn-secondary:hover{background:var(--line);}
26094 .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;}
26095 .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;}
26096 .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;}
26097 @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));}}
26098 .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;}
26099 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26100 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26101 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26102 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26103 .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;}
26104 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26105 body.dark-theme .relocate-input{background:var(--surface-2);}
26106 </style>
26107</head>
26108<body>
26109 <div class="background-watermarks" aria-hidden="true">
26110 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26111 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26112 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26113 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26114 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26115 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26116 </div>
26117 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26118 <div class="top-nav">
26119 <div class="top-nav-inner">
26120 <a class="brand" href="/">
26121 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26122 <div class="brand-copy">
26123 <div class="brand-title">OxideSLOC</div>
26124 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26125 </div>
26126 </a>
26127 <div class="nav-right">
26128 <a class="nav-pill" href="/">Home</a>
26129 <div class="nav-dropdown">
26130 <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>
26131 <div class="nav-dropdown-menu">
26132 <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>
26133 </div>
26134 </div>
26135 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26136 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26137 <div class="nav-dropdown">
26138 <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>
26139 <div class="nav-dropdown-menu">
26140 <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>
26141 </div>
26142 </div>
26143 <div class="server-status-wrap" id="server-status-wrap">
26144 <div class="nav-pill server-online-pill" id="server-status-pill">
26145 <span class="status-dot" id="status-dot"></span>
26146 <span id="server-status-label">Server</span>
26147 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26148 </div>
26149 <div class="server-status-tip">
26150 OxideSLOC is running — accessible on your network.
26151 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26152 </div>
26153 </div>
26154 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26155 <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>
26156 </button>
26157 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26158 <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>
26159 <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>
26160 </button>
26161 </div>
26162 </div>
26163 </div>
26164
26165 <div class="page">
26166 <div class="panel">
26167 <h1>Scan Files Moved</h1>
26168 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26169 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26170 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26171 <div class="relocate-section">
26172 <h2>Locate Scan Output</h2>
26173 <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>
26174 <div class="relocate-row">
26175 <input type="text" id="relocate-folder" name="folder_path"
26176 value="{{ folder_hint }}"
26177 placeholder="Path to folder containing scan output..."
26178 class="relocate-input" autocomplete="off" spellcheck="false">
26179 {% if !server_mode %}
26180 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
26181 {% endif %}
26182 </div>
26183 <div style="margin-top:12px;">
26184 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26185 </div>
26186 </div>
26187 <div class="actions">
26188 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26189 <a class="btn-secondary" href="/view-reports">View Reports</a>
26190 </div>
26191 </div>
26192 </div>
26193 <footer class="site-footer">
26194 oxide-sloc v{{ version }} — local code metrics workbench ·
26195 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26196 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26197 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26198 · <a href="/api-docs" rel="noopener">REST API</a>
26199 </footer>
26200 <script nonce="{{ csp_nonce }}">
26201 (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");});})();
26202 (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);}})();
26203 (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;});})();
26204 </script>
26205 <script nonce="{{ csp_nonce }}">
26206 (function(){
26207 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'}];
26208 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);});}
26209 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26210 function init(){
26211 var btn=document.getElementById('settings-btn');if(!btn)return;
26212 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26213 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>';
26214 document.body.appendChild(m);
26215 var g=document.getElementById('scheme-grid');
26216 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);});
26217 var cl=document.getElementById('settings-close');
26218 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);
26219 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');});
26220 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26221 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26222 }
26223 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26224 }());
26225 (function(){
26226 var browseBtn=document.getElementById('browse-relocate-btn');
26227 if(browseBtn){
26228 browseBtn.addEventListener('click',function(){
26229 browseBtn.disabled=true;browseBtn.textContent='...';
26230 var inp=document.getElementById('relocate-folder');
26231 var hint=inp?inp.value:'';
26232 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
26233 .then(function(r){return r.ok?r.json():{cancelled:true};})
26234 .then(function(d){
26235 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26236 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26237 })
26238 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26239 });
26240 }
26241 var restoreBtn=document.getElementById('restore-btn');
26242 var errBox=document.getElementById('relocate-error-box');
26243 var okBox=document.getElementById('relocate-success-box');
26244 if(restoreBtn){
26245 restoreBtn.addEventListener('click',function(){
26246 var inp=document.getElementById('relocate-folder');
26247 var folder=inp?inp.value.trim():'';
26248 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26249 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26250 var body=new URLSearchParams();
26251 body.set('run_id','{{ run_id }}');
26252 body.set('redirect_url','{{ redirect_url }}');
26253 body.set('folder_path',folder);
26254 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26255 .then(function(r){return r.json();})
26256 .then(function(d){
26257 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26258 if(d&&d.ok){
26259 if(errBox)errBox.classList.add('hidden');
26260 if(okBox){okBox.style.display='block';}
26261 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26262 } else {
26263 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26264 }
26265 })
26266 .catch(function(e){
26267 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26268 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26269 });
26270 });
26271 }
26272 }());
26273 </script>
26274 <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]';
26275 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;}
26276 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>
26277</body>
26278</html>
26279"##,
26280 ext = "html"
26281)]
26282struct RelocateScanTemplate {
26283 message: String,
26284 run_id: String,
26285 folder_hint: String,
26286 redirect_url: String,
26287 server_mode: bool,
26288 csp_nonce: String,
26289 version: &'static str,
26290}
26291
26292#[derive(Template)]
26295#[template(
26296 source = r##"
26297<!doctype html>
26298<html lang="en">
26299<head>
26300 <meta charset="utf-8">
26301 <meta name="viewport" content="width=device-width, initial-scale=1">
26302 <title>OxideSLOC | View Reports</title>
26303 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26304 <style nonce="{{ csp_nonce }}">
26305 :root {
26306 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26307 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26308 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26309 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26310 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26311 }
26312 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; }
26313 *{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;}
26314 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26315 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26316 .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
26317 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26318 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
26319 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26320 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26321 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26322 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26323 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
26324 .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
26325 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26326 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26327 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26328 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26329 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26330 .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26331 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26332 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26333 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26334 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26335 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26336 .settings-modal-body{padding:14px 16px 16px;}
26337 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26338 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26339 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26340 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26341 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26342 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26343 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26344 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26345 .tz-select:focus{border-color:var(--oxide);}
26346 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26347 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26348 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26349 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26350 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26351 .panel-meta{font-size:13px;color:var(--muted);}
26352 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26353 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26354 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26355 .per-page-label{font-size:13px;color:var(--muted);}
26356 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;}
26357 .filter-input{min-width:180px;cursor:text;}
26358 .table-wrap{width:100%;overflow-x:auto;}
26359 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26360 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;}
26361 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26362 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26363 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26364 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26365 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26366 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26367 tr:last-child td{border-bottom:none;}
26368 tr:hover td{background:var(--surface-2);}
26369 .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);}
26370 .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);}
26371 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26372 .metric-num{font-weight:700;color:var(--text);}
26373 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26374 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26375 .git-commit-chip{cursor:help;}
26376 .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;}
26377 .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;}
26378 .btn:hover{background:var(--line);}
26379 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26380 .btn.primary:hover{opacity:.9;}
26381 .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;}
26382 .btn-back:hover{background:var(--line);}
26383 .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;}
26384 .export-btn:hover{background:var(--line);}
26385 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26386 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26387 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26388 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26389 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26390 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26391 .pagination-info{font-size:13px;color:var(--muted);}
26392 .pagination-btns{display:flex;gap:6px;}
26393 .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;}
26394 .pg-btn:hover:not(:disabled){background:var(--line);}
26395 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26396 .pg-btn:disabled{opacity:.35;cursor:default;}
26397 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26398 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26399 .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);}
26400 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26401 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26402 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26403 .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);}
26404 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26405 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26406 .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;}
26407 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26408 .site-footer a{color:var(--muted);}
26409 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26410 .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%;}
26411 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26412 .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;}
26413 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26414 .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;}
26415 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26416 .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;}
26417 .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;}
26418 .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;}
26419 @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));}}
26420 .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;}
26421 .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;}
26422 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26423 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26424 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26425 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26426 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26427 .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;}
26428 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26429 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26430 .watched-chip-rm:hover{color:var(--oxide);}
26431 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26432 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26433 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26434 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26435 .rpt-btn{min-width:58px;justify-content:center;}
26436 .flex-row{display:flex;align-items:center;gap:8px;}
26437 .report-cell{overflow:visible;white-space:normal;}
26438 #history-table col:nth-child(1){width:185px;}
26439 #history-table col:nth-child(2){width:220px;}
26440 #history-table col:nth-child(3){width:100px;}
26441 #history-table col:nth-child(4){width:72px;}
26442 #history-table col:nth-child(5){width:82px;}
26443 #history-table col:nth-child(6){width:82px;}
26444 #history-table col:nth-child(7){width:65px;}
26445 #history-table col:nth-child(8){width:90px;}
26446 #history-table col:nth-child(9){width:85px;}
26447 #history-table col:nth-child(10){width:115px;}
26448 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26449 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26450 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26451 .submod-details summary::-webkit-details-marker{display:none;}
26452.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26453 .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;}
26454 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26455 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26456 </style>
26457</head>
26458<body>
26459 <div class="background-watermarks" aria-hidden="true">
26460 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26461 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26462 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26463 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26464 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26465 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26466 </div>
26467 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26468 <div class="top-nav">
26469 <div class="top-nav-inner">
26470 <a class="brand" href="/">
26471 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26472 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26473 </a>
26474 <div class="nav-right">
26475 <a class="nav-pill" href="/">Home</a>
26476 <div class="nav-dropdown">
26477 <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>
26478 <div class="nav-dropdown-menu">
26479 <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>
26480 </div>
26481 </div>
26482 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26483 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26484 <div class="nav-dropdown">
26485 <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>
26486 <div class="nav-dropdown-menu">
26487 <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>
26488 </div>
26489 </div>
26490 <div class="server-status-wrap" id="server-status-wrap">
26491 <div class="nav-pill server-online-pill" id="server-status-pill">
26492 <span class="status-dot" id="status-dot"></span>
26493 <span id="server-status-label">Server</span>
26494 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26495 </div>
26496 <div class="server-status-tip">
26497 OxideSLOC is running — accessible on your network.
26498 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26499 </div>
26500 </div>
26501 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26502 <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>
26503 </button>
26504 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26505 <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>
26506 <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>
26507 </button>
26508 </div>
26509 </div>
26510 </div>
26511
26512 <div class="page">
26513 {% if let Some(err) = browse_error %}
26514 <div class="toast-error">
26515 <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>
26516 {{ err }}
26517 </div>
26518 {% endif %}
26519 {% if linked_count > 0 %}
26520 <div class="toast-success">
26521 <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>
26522 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26523 </div>
26524 {% endif %}
26525 <div class="watched-bar">
26526 <div class="watched-bar-left">
26527 <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>
26528 <span class="watched-label">Watched Folders</span>
26529 <div class="watched-chips">
26530 {% if server_mode %}
26531 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26532 {% else %}
26533 {% for dir in watched_dirs %}
26534 <span class="watched-chip">
26535 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26536 <form method="POST" action="/watched-dirs/remove" style="display:contents">
26537 <input type="hidden" name="folder_path" value="{{ dir }}">
26538 <input type="hidden" name="redirect_to" value="/view-reports">
26539 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
26540 </form>
26541 </span>
26542 {% endfor %}
26543 {% if watched_dirs.is_empty() %}
26544 <span class="watched-none">No folders watched — click Choose to add one</span>
26545 {% endif %}
26546 {% endif %}
26547 </div>
26548 </div>
26549 {% if !server_mode %}
26550 <div class="watched-bar-right">
26551 <button type="button" class="btn" id="add-watched-btn">
26552 <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>
26553 Choose
26554 </button>
26555 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26556 <input type="hidden" name="redirect_to" value="/view-reports">
26557 <button type="submit" class="btn">↻ Refresh</button>
26558 </form>
26559 </div>
26560 {% endif %}
26561 </div>
26562 {% if total_scans > 0 %}
26563 <div class="summary-strip">
26564 <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>
26565 <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>
26566 <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>
26567 <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>
26568 </div>
26569 {% endif %}
26570
26571 <section class="panel">
26572 <div class="panel-header">
26573 <div>
26574 <h1>View Reports</h1>
26575 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26576 {% 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 %}
26577 </div>
26578 <div class="flex-row">
26579 <button type="button" class="export-btn" id="export-csv-btn">
26580 <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>
26581 Export CSV
26582 </button>
26583 <button type="button" class="export-btn" id="export-xls-btn">
26584 <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>
26585 Export Excel
26586 </button>
26587 </div>
26588 </div>
26589
26590 {% if entries.is_empty() %}
26591 <div class="empty-state">
26592 <strong>No reports with viewable HTML yet</strong>
26593 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.
26594 </div>
26595 {% else %}
26596 <div class="filter-row">
26597 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
26598 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26599 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
26600 </div>
26601 <div class="table-wrap">
26602 <table id="history-table">
26603 <colgroup>
26604 <col><col><col><col><col><col><col><col><col><col>
26605 </colgroup>
26606 <thead>
26607 <tr id="history-thead">
26608 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26609 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26610 <th>Run ID<div class="col-resize-handle"></div></th>
26611 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26612 <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>
26613 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26614 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26615 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26616 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26617 <th>Report<div class="col-resize-handle"></div></th>
26618 </tr>
26619 </thead>
26620 <tbody id="history-tbody">
26621 {% for entry in entries %}
26622 <tr class="history-row" data-run="{{ entry.run_id }}"
26623 data-timestamp="{{ entry.timestamp }}"
26624 data-project="{{ entry.project_label }}"
26625 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26626 data-skipped="{{ entry.files_skipped }}"
26627 data-comments="{{ entry.comment_lines }}"
26628 data-blank="{{ entry.blank_lines }}"
26629 data-physical="{{ entry.total_physical_lines }}"
26630 data-functions="{{ entry.functions }}"
26631 data-classes="{{ entry.classes }}"
26632 data-variables="{{ entry.variables }}"
26633 data-imports="{{ entry.imports }}"
26634 data-tests="{{ entry.test_count }}"
26635 data-branch="{{ entry.git_branch }}"
26636 data-commit="{{ entry.git_commit }}"
26637 data-has-json="{{ entry.has_json }}"
26638 data-html-url="/runs/html/{{ entry.run_id }}">
26639 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26640 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26641 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26642 <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>
26643 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26644 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26645 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26646 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
26647 <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>
26648 <td class="report-cell">
26649 <div class="actions-cell">
26650 {% 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 %}
26651 {% 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 %}
26652 </div>
26653 {% if !entry.submodule_links.is_empty() %}
26654 <details class="submod-details">
26655 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
26656 <div class="submod-link-list">
26657 {% for sub in entry.submodule_links %}
26658 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26659 {% endfor %}
26660 </div>
26661 </details>
26662 {% endif %}
26663 </td>
26664 </tr>
26665 {% endfor %}
26666 </tbody>
26667 </table>
26668 </div>
26669 <div class="pagination">
26670 <span class="pagination-info" id="pagination-info"></span>
26671 <div class="pagination-btns" id="pagination-btns"></div>
26672 <div class="flex-row">
26673 <span class="per-page-label">Show</span>
26674 <select class="per-page" id="per-page-sel">
26675 <option value="10">10 per page</option>
26676 <option value="25" selected>25 per page</option>
26677 <option value="50">50 per page</option>
26678 <option value="100">100 per page</option>
26679 </select>
26680 <span class="per-page-label" id="page-range-label"></span>
26681 </div>
26682 </div>
26683 {% endif %}
26684 </section>
26685 </div>
26686
26687 <footer class="site-footer">
26688 local code analysis - metrics, history and reports
26689 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26690 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26691 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26692 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26693 · <a href="/api-docs" rel="noopener">REST API</a>
26694 </footer>
26695
26696 <script nonce="{{ csp_nonce }}">
26697 (function () {
26698 // ── Theme ──────────────────────────────────────────────────────────────
26699 var storageKey = 'oxide-sloc-theme';
26700 var body = document.body;
26701 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26702 var toggle = document.getElementById('theme-toggle');
26703 if (toggle) toggle.addEventListener('click', function () {
26704 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26705 body.classList.toggle('dark-theme', next === 'dark');
26706 try { localStorage.setItem(storageKey, next); } catch(e) {}
26707 });
26708
26709 // ── State ─────────────────────────────────────────────────────────────
26710 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26711 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26712 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26713
26714 // Aggregate stats from first (most recent) row
26715 if (allRows.length) {
26716 var first = allRows[0];
26717 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();}
26718 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>':'');}
26719 setChipVal('agg-code', first.dataset.code);
26720 setChipVal('agg-files', first.dataset.files);
26721 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26722 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26723 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(); });
26724 }
26725
26726 // ── Branch filter population ──────────────────────────────────────────
26727 (function() {
26728 var branches = {};
26729 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26730 var sel = document.getElementById('branch-filter');
26731 if (sel) Object.keys(branches).sort().forEach(function(b) {
26732 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26733 });
26734 })();
26735
26736 // ── Filter ────────────────────────────────────────────────────────────
26737 function getFilteredRows() {
26738 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26739 var branch = ((document.getElementById('branch-filter') || {}).value || '');
26740 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26741 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26742 if (branch && (r.dataset.branch || '') !== branch) return false;
26743 return true;
26744 });
26745 }
26746
26747 // ── Pagination ────────────────────────────────────────────────────────
26748 function renderPage() {
26749 var filtered = getFilteredRows();
26750 var total = filtered.length;
26751 var totalPages = Math.max(1, Math.ceil(total / perPage));
26752 currentPage = Math.min(currentPage, totalPages);
26753 var start = (currentPage - 1) * perPage;
26754 var end = Math.min(start + perPage, total);
26755 var shown = {};
26756 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26757 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26758 r.style.display = shown[r.dataset.run] ? '' : 'none';
26759 });
26760 var rl = document.getElementById('page-range-label');
26761 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26762 var info = document.getElementById('pagination-info');
26763 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26764 var btns = document.getElementById('pagination-btns');
26765 if (!btns) return;
26766 btns.innerHTML = '';
26767 function makeBtn(lbl, pg, active, disabled) {
26768 var b = document.createElement('button');
26769 b.className = 'pg-btn' + (active ? ' active' : '');
26770 b.textContent = lbl; b.disabled = disabled;
26771 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26772 return b;
26773 }
26774 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26775 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26776 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26777 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26778 }
26779
26780 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26781 window.applyFilters = function() { currentPage = 1; renderPage(); };
26782
26783 // ── Sorting ───────────────────────────────────────────────────────────
26784 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26785 function doSort(col, type, order) {
26786 var tbody = document.getElementById('history-tbody');
26787 if (!tbody) return;
26788 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26789 rows.sort(function(a, b) {
26790 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26791 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26792 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26793 return va < vb ? 1 : va > vb ? -1 : 0;
26794 });
26795 rows.forEach(function(r) { tbody.appendChild(r); });
26796 currentPage = 1; renderPage();
26797 }
26798 sortHeaders.forEach(function(th) {
26799 th.addEventListener('click', function(e) {
26800 if (e.target.classList.contains('col-resize-handle')) return;
26801 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26802 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26803 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26804 th.classList.add('sort-' + sortOrder);
26805 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26806 doSort(col, type, sortOrder);
26807 });
26808 });
26809
26810 // ── Column resize ─────────────────────────────────────────────────────
26811 (function() {
26812 var table = document.getElementById('history-table');
26813 if (!table) return;
26814 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26815 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26816 ths.forEach(function(th, i) {
26817 var handle = th.querySelector('.col-resize-handle');
26818 if (!handle || !cols[i]) return;
26819 var startX, startW;
26820 handle.addEventListener('mousedown', function(e) {
26821 e.stopPropagation(); e.preventDefault();
26822 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26823 handle.classList.add('dragging');
26824 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26825 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26826 document.addEventListener('mousemove', onMove);
26827 document.addEventListener('mouseup', onUp);
26828 });
26829 });
26830 })();
26831
26832 // ── Full-commit hover tooltip ─────────────────────────────────────────
26833 // The commit chips live inside an overflow:auto table wrapper, which would
26834 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26835 // (escaping the scroll container) and follow the cursor. Event delegation
26836 // keeps it working after pagination/sorting re-renders the rows.
26837 (function() {
26838 var tip = document.createElement('div');
26839 tip.className = 'commit-tip';
26840 tip.setAttribute('role', 'tooltip');
26841 document.body.appendChild(tip);
26842 var shown = false;
26843 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26844 function place(e) {
26845 var pad = 14, r = tip.getBoundingClientRect();
26846 var x = e.clientX + pad, y = e.clientY + pad;
26847 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26848 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26849 tip.style.left = x + 'px'; tip.style.top = y + 'px';
26850 }
26851 function hide() { tip.style.display = 'none'; shown = false; }
26852 document.addEventListener('mouseover', function(e) {
26853 var chip = chipFrom(e.target);
26854 if (!chip) return;
26855 var full = chip.getAttribute('data-full-commit');
26856 if (!full) return;
26857 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26858 });
26859 document.addEventListener('mousemove', function(e) {
26860 if (!shown) return;
26861 if (chipFrom(e.target)) place(e); else hide();
26862 });
26863 document.addEventListener('mouseout', function(e) {
26864 if (chipFrom(e.target)) hide();
26865 });
26866 })();
26867
26868 // ── Reset view ────────────────────────────────────────────────────────
26869 window.resetView = function() {
26870 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26871 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26872 sortCol = null; sortOrder = 'asc';
26873 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26874 var tbody = document.getElementById('history-tbody');
26875 if (tbody) {
26876 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26877 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26878 rows.forEach(function(r) { tbody.appendChild(r); });
26879 }
26880 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26881 var table = document.getElementById('history-table');
26882 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26883 currentPage = 1; renderPage();
26884 };
26885
26886 renderPage();
26887
26888 // ── Export helpers ────────────────────────────────────────────────────
26889 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
26890 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26891 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);}
26892 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;');}
26893 function slocXlsx(fname,sheet,hdrs,rows){
26894 var enc=new TextEncoder();
26895 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;}
26896 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;}
26897 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26898 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26899 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26900 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;}
26901 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26902 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];}
26903 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26904 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
26905 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26906 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26907 +'<fonts count="2">'
26908 +'<font><sz val="11"/><name val="Calibri"/></font>'
26909 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26910 +'</fonts>'
26911 +'<fills count="3">'
26912 +'<fill><patternFill patternType="none"/></fill>'
26913 +'<fill><patternFill patternType="gray125"/></fill>'
26914 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26915 +'</fills>'
26916 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
26917 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
26918 +'<cellXfs count="4">'
26919 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
26920 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
26921 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
26922 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
26923 +'</cellXfs>'
26924 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
26925 +'</styleSheet>';
26926 var rx='<row r="1">';
26927 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
26928 rx+='</row>';
26929 rows.forEach(function(row,ri){
26930 var rn=ri+2;rx+='<row r="'+rn+'">';
26931 row.forEach(function(cell,c){
26932 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
26933 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
26934 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
26935 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
26936 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
26937 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
26938 });
26939 rx+='</row>';
26940 });
26941 var lastCol=hdrs.length,lastRow=rows.length+1;
26942 var tableRef='A1:'+colNm(lastCol)+lastRow;
26943 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26944 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
26945 +'<autoFilter ref="'+tableRef+'"/>'
26946 +'<tableColumns count="'+lastCol+'">'
26947 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
26948 +'</tableColumns>'
26949 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
26950 +'</table>';
26951 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26952 +'<Relationships xmlns="'+pns+'relationships">'
26953 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
26954 +'</Relationships>';
26955 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>';
26956 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
26957 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
26958 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
26959 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
26960 +'</worksheet>';
26961 var F={
26962 '[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>',
26963 '_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>',
26964 '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>',
26965 '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>',
26966 'xl/styles.xml':stl,
26967 'xl/sharedStrings.xml':ssXml,
26968 'xl/worksheets/sheet1.xml':sh,
26969 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
26970 'xl/tables/table1.xml':tableXml
26971 };
26972 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'];
26973 var zparts=[],zcds=[],zoff=0,znf=0;
26974 order.forEach(function(name){
26975 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26976 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]);
26977 var entry=new Uint8Array(lha.length+nb.length+sz);
26978 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26979 zparts.push(entry);
26980 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));
26981 var cde=new Uint8Array(cda.length+nb.length);
26982 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26983 zcds.push(cde);zoff+=entry.length;znf++;
26984 });
26985 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26986 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]);
26987 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26988 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26989 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26990 zout.set(new Uint8Array(ea),zpos);
26991 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
26992 }
26993
26994 // Multi-sheet XLSX builder for the scan-history export.
26995 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
26996 function slocXlsxMulti(fname,sheets){
26997 var enc=new TextEncoder();
26998 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;}
26999 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;}
27000 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27001 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27002 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27003 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];}
27004 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;}
27005 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27006 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27007 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27008 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27009 +'<fonts count="3">'
27010 +'<font><sz val="11"/><name val="Calibri"/></font>'
27011 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27012 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27013 +'</fonts>'
27014 +'<fills count="4">'
27015 +'<fill><patternFill patternType="none"/></fill>'
27016 +'<fill><patternFill patternType="gray125"/></fill>'
27017 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27018 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27019 +'</fills>'
27020 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27021 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27022 +'<cellXfs count="7">'
27023 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27024 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27025 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27026 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27027 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27028 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27029 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27030 +'</cellXfs>'
27031 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27032 +'</styleSheet>';
27033 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27034 sheets.forEach(function(sh,sheetIdx){
27035 var rx='<row r="1">';
27036 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27037 rx+='</row>';
27038 var rn=2;
27039 sh.rows.forEach(function(row){
27040 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27041 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27042 rx+='<row r="'+rn+'">';
27043 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27044 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27045 rx+='</row>';rn++;return;
27046 }
27047 rx+='<row r="'+rn+'">';
27048 row.forEach(function(cell,c){
27049 var ref=colRef(c,rn);
27050 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27051 if(typeof cell==='object'&&cell!==null){
27052 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27053 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27054 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27055 return;
27056 }
27057 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27058 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27059 });
27060 rx+='</row>';rn++;
27061 });
27062 var cw='';
27063 if(sh.colWidths&&sh.colWidths.length>0){
27064 cw='<cols>';
27065 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27066 cw+='</cols>';
27067 }
27068 var tblParts='';
27069 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27070 tableCounter++;
27071 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27072 var tRef='A1:'+colNm(colCount)+rowCount;
27073 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27074 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27075 +'<autoFilter ref="'+tRef+'"/>'
27076 +'<tableColumns count="'+colCount+'">'
27077 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27078 +'</tableColumns>'
27079 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27080 +'</table>';
27081 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27082 +'<Relationships xmlns="'+pns+'relationships">'
27083 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27084 +'</Relationships>';
27085 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27086 }
27087 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27088 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27089 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27090 });
27091 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>';
27092 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27093 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27094 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>';
27095 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27096 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27097 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27098 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27099 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27100 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27101 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};
27102 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27103 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27104 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27105 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27106 var zparts=[],zcds=[],zoff=0,znf=0;
27107 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++;});
27108 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27109 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]);
27110 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27111 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27112 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27113 zout.set(new Uint8Array(ea),zpos);
27114 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27115 }
27116
27117 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'};
27118 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27119
27120 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'];
27121 function getHistoryRows(){
27122 var r=[];
27123 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27124 var code=Number(tr.getAttribute('data-code'))||0;
27125 var phys=Number(tr.getAttribute('data-physical'))||0;
27126 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27127 r.push([
27128 tr.getAttribute('data-timestamp')||'',
27129 tr.getAttribute('data-project')||'',
27130 tr.getAttribute('data-run')||'',
27131 tr.getAttribute('data-physical')||'',
27132 tr.getAttribute('data-code')||'',
27133 tr.getAttribute('data-comments')||'',
27134 tr.getAttribute('data-blank')||'',
27135 tr.getAttribute('data-files')||'',
27136 tr.getAttribute('data-skipped')||'',
27137 tr.getAttribute('data-functions')||'',
27138 tr.getAttribute('data-classes')||'',
27139 tr.getAttribute('data-variables')||'',
27140 tr.getAttribute('data-imports')||'',
27141 tr.getAttribute('data-tests')||'',
27142 dens,
27143 tr.getAttribute('data-branch')||'',
27144 tr.getAttribute('data-commit')||''
27145 ]);
27146 });
27147 return r;
27148 }
27149 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27150 window.exportHistoryXls = function(){
27151 var histRows=getHistoryRows();
27152 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27153 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]];});
27154 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]};
27155 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27156 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27157 var runId=jsonRow.getAttribute('data-run')||'';
27158 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27159 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27160 fetch('/runs/json/'+runId)
27161 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27162 .then(function(run){
27163 var tot=run.summary_totals||{};
27164 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27165 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27166 function B(v){return{v:v,s:4};}
27167 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27168 var sumRows=[
27169 [{_sec:true,v:'RUN INFORMATION'}],
27170 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27171 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27172 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27173 [B('Branch'),run.git_branch||''],
27174 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27175 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27176 [B('Files Analyzed'),N(tot.files_analyzed)],
27177 [B('Files Skipped'),N(tot.files_skipped)],
27178 [],
27179 [{_sec:true,v:'CODE METRICS'}],
27180 [B('Physical Lines'),N(phys)],
27181 [B('Code Lines'),N(code)],
27182 [B('Comments'),N(tot.comment_lines)],
27183 [B('Blank Lines'),N(tot.blank_lines)],
27184 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27185 [B('Functions'),N(tot.functions)],
27186 [B('Classes / Types'),N(tot.classes)],
27187 [B('Variables'),N(tot.variables)],
27188 [B('Imports'),N(tot.imports)],
27189 [B('Tests'),N(tot.test_count)],
27190 [B('Assertions'),N(tot.test_assertion_count)],
27191 [B('Test Suites'),N(tot.test_suite_count)],
27192 [B('Code Density'),{v:dens,s:6}],
27193 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27194 ];
27195 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27196 var langRows=(run.totals_by_language||[]).map(function(l){
27197 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27198 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27199 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];
27200 });
27201 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27202 var pfRows=(run.per_file_records||[]).map(function(r){
27203 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27204 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];
27205 });
27206 var skHdrs=['File','Status','Size (bytes)'];
27207 var skRows=(run.skipped_file_records||[]).map(function(r){
27208 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27209 });
27210 slocXlsxMulti('scan-history.xlsx',[
27211 histSheet,
27212 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27213 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27214 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27215 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27216 ]);
27217 })
27218 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27219 };
27220
27221 var csvBtn = document.getElementById('export-csv-btn');
27222 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27223 var xlsBtn = document.getElementById('export-xls-btn');
27224 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27225
27226 // ── Remaining CSP-safe event bindings ────────────────────────────────
27227 (function wireEvents() {
27228 var el;
27229 el = document.getElementById('reset-view-btn');
27230 if (el) el.addEventListener('click', window.resetView);
27231 el = document.getElementById('project-filter');
27232 if (el) el.addEventListener('input', window.applyFilters);
27233 el = document.getElementById('branch-filter');
27234 if (el) el.addEventListener('change', window.applyFilters);
27235 el = document.getElementById('per-page-sel');
27236 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27237 el = document.getElementById('add-watched-btn');
27238 if (el) el.addEventListener('click', function() {
27239 fetch('/pick-directory?kind=reports')
27240 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27241 .then(function(data) {
27242 if (!data.cancelled && data.selected_path) {
27243 var form = document.createElement('form');
27244 form.method = 'POST';
27245 form.action = '/watched-dirs/add';
27246 var ri = document.createElement('input');
27247 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27248 var fi = document.createElement('input');
27249 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27250 form.appendChild(ri); form.appendChild(fi);
27251 document.body.appendChild(form);
27252 form.submit();
27253 }
27254 })
27255 .catch(function(e) { alert('Could not open folder picker: ' + e); });
27256 });
27257 })();
27258
27259 (function randomizeWatermarks() {
27260 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27261 if (!wms.length) return;
27262 var placed = [];
27263 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;}
27264 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];}
27265 var half=Math.floor(wms.length/2);
27266 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;});
27267 })();
27268
27269 (function spawnCodeParticles() {
27270 var container = document.getElementById('code-particles');
27271 if (!container) return;
27272 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'];
27273 for (var i = 0; i < 38; i++) {
27274 (function(idx) {
27275 var el = document.createElement('span');
27276 el.className = 'code-particle';
27277 el.textContent = snippets[idx % snippets.length];
27278 var left = Math.random() * 94 + 2;
27279 var top = Math.random() * 88 + 6;
27280 var dur = (Math.random() * 10 + 9).toFixed(1);
27281 var delay = (Math.random() * 18).toFixed(1);
27282 var rot = (Math.random() * 26 - 13).toFixed(1);
27283 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27284 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';
27285 container.appendChild(el);
27286 })(i);
27287 }
27288 })();
27289 })();
27290 </script>
27291 <script nonce="{{ csp_nonce }}">
27292 (function(){
27293 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'}];
27294 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);});}
27295 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27296 function init(){
27297 var btn=document.getElementById('settings-btn');if(!btn)return;
27298 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27299 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>';
27300 document.body.appendChild(m);
27301 var g=document.getElementById('scheme-grid');
27302 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);});
27303 var cl=document.getElementById('settings-close');
27304 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);
27305 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');});
27306 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27307 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27308 }
27309 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27310 }());
27311 </script>
27312 <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>
27313</body>
27314</html>
27315"##,
27316 ext = "html"
27317)]
27318struct HistoryTemplate {
27319 version: &'static str,
27320 entries: Vec<HistoryEntryRow>,
27321 total_scans: usize,
27322 linked_count: usize,
27323 browse_error: Option<String>,
27324 watched_dirs: Vec<String>,
27325 csp_nonce: String,
27326 server_mode: bool,
27327}
27328
27329#[derive(Template)]
27332#[template(
27333 source = r##"
27334<!doctype html>
27335<html lang="en">
27336<head>
27337 <meta charset="utf-8">
27338 <meta name="viewport" content="width=device-width, initial-scale=1">
27339 <title>OxideSLOC | Compare Scans</title>
27340 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27341 <style nonce="{{ csp_nonce }}">
27342 :root {
27343 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27344 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27345 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27346 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27347 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27348 }
27349 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27350 *{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;}
27351 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27352 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27353 .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);}
27354 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27355 .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));}
27356 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27357 .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;}
27358 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27359 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27360 @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; } }
27361 .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;}
27362 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27363 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27364 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27365 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27366 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27367 .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;}
27368 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27369 .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);}
27370 .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;}
27371 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27372 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27373 .settings-modal-body{padding:14px 16px 16px;}
27374 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27375 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27376 .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;}
27377 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27378 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27379 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27380 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27381 .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;}
27382 .tz-select:focus{border-color:var(--oxide);}
27383 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27384 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27385 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27386 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27387 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27388 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27389 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27390 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27391 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27392 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27393 .per-page-label{font-size:13px;color:var(--muted);}
27394 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;}
27395 .filter-input{min-width:180px;cursor:text;}
27396 .table-wrap{width:100%;overflow-x:auto;}
27397 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27398 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;}
27399 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27400 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27401 #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;}
27402 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27403 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27404 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27405 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27406 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27407 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27408 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27409 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27410 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27411 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27412 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27413 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27414 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27415 tr:last-child td{border-bottom:none;}
27416 tr.selected td{background:var(--sel-bg);}
27417 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27418 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27419 tr{cursor:pointer;}
27420 tr.row-locked{opacity:.35;cursor:not-allowed;}
27421 tr.row-locked td{pointer-events:none;}
27422 .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;}
27423 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27424 .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;}
27425 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27426 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27427 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27428 .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);}
27429 .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);}
27430 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27431 .metric-num{font-weight:700;color:var(--text);}
27432 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27433 .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;}
27434 .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;}
27435 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27436 .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;}
27437 .btn:hover{background:var(--line);}
27438 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27439 .btn.primary:hover{opacity:.9;}
27440 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27441 .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;}
27442 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27443 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27444 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27445 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27446 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27447 .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;}
27448 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27449 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27450 .watched-chip-rm:hover{color:var(--oxide);}
27451 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27452 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27453 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27454 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27455 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27456 .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;}
27457 .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;}
27458 .btn-back:hover{background:var(--line);}
27459 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27460 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27461 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27462 .pagination-info{font-size:13px;color:var(--muted);}
27463 .pagination-btns{display:flex;gap:6px;}
27464 .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;}
27465 .pg-btn:hover:not(:disabled){background:var(--line);}
27466 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27467 .pg-btn:disabled{opacity:.35;cursor:default;}
27468 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27469 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27470 .site-footer a{color:var(--muted);}
27471 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27472 .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;}
27473 .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;}
27474 .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;}
27475 @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));}}
27476 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27477 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27478 .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);}
27479 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27480 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27481 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27482 .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);}
27483 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27484 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27485 .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;}
27486 .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;}
27487 .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%;}
27488 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27489 .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;}
27490 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27491 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27492 .hidden{display:none!important;}
27493 .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%;}
27494 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27495 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27496 .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;}
27497 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27498 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27499 .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;}
27500 .scope-option:hover{background:var(--line);}
27501 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27502 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27503 .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;}
27504 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27505 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27506 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27507 .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;}
27508 </style>
27509</head>
27510<body>
27511 <div class="background-watermarks" aria-hidden="true">
27512 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27513 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27514 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27515 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27516 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27517 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27518 </div>
27519 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27520 <div class="top-nav">
27521 <div class="top-nav-inner">
27522 <a class="brand" href="/">
27523 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27524 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27525 </a>
27526 <div class="nav-right">
27527 <a class="nav-pill" href="/">Home</a>
27528 <div class="nav-dropdown">
27529 <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>
27530 <div class="nav-dropdown-menu">
27531 <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>
27532 </div>
27533 </div>
27534 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27535 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27536 <div class="nav-dropdown">
27537 <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>
27538 <div class="nav-dropdown-menu">
27539 <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>
27540 </div>
27541 </div>
27542 <div class="server-status-wrap" id="server-status-wrap">
27543 <div class="nav-pill server-online-pill" id="server-status-pill">
27544 <span class="status-dot" id="status-dot"></span>
27545 <span id="server-status-label">Server</span>
27546 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27547 </div>
27548 <div class="server-status-tip">
27549 OxideSLOC is running — accessible on your network.
27550 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27551 </div>
27552 </div>
27553 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27554 <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>
27555 </button>
27556 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27557 <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>
27558 <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>
27559 </button>
27560 </div>
27561 </div>
27562 </div>
27563
27564 <div class="page">
27565 <div class="watched-bar">
27566 <div class="watched-bar-left">
27567 <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>
27568 <span class="watched-label">Watched Folders</span>
27569 <div class="watched-chips">
27570 {% if server_mode %}
27571 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27572 {% else %}
27573 {% for dir in watched_dirs %}
27574 <span class="watched-chip">
27575 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27576 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27577 <input type="hidden" name="folder_path" value="{{ dir }}">
27578 <input type="hidden" name="redirect_to" value="/compare-scans">
27579 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27580 </form>
27581 </span>
27582 {% endfor %}
27583 {% if watched_dirs.is_empty() %}
27584 <span class="watched-none">No folders watched — click Choose to add one</span>
27585 {% endif %}
27586 {% endif %}
27587 </div>
27588 </div>
27589 {% if !server_mode %}
27590 <div class="watched-bar-right">
27591 <button type="button" class="btn" id="add-watched-btn">
27592 <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>
27593 Choose
27594 </button>
27595 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27596 <input type="hidden" name="redirect_to" value="/compare-scans">
27597 <button type="submit" class="btn">↻ Refresh</button>
27598 </form>
27599 </div>
27600 {% endif %}
27601 </div>
27602 {% if total_scans > 0 %}
27603 <div class="summary-strip">
27604 <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>
27605 <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>
27606 <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>
27607 <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>
27608 </div>
27609 {% endif %}
27610 <section class="panel">
27611 <div class="panel-header">
27612 <div>
27613 <h1>Compare Scans</h1>
27614 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27615 </div>
27616 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27617 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27618 <button class="btn primary" id="compare-btn" disabled>
27619 <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>
27620 Compare <span class="sel-count" id="sel-count">0</span> Selected
27621 </button>
27622 </div>
27623 </div>
27624 </div>
27625
27626 {% if entries.is_empty() %}
27627 <div class="empty-state">
27628 <strong>No scans yet</strong>
27629 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.
27630 </div>
27631 {% else %}
27632 <div class="filter-row">
27633 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27634 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27635 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27636 </div>
27637 <div class="scope-panel hidden" id="scope-panel">
27638 <div class="scope-panel-label">
27639 <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>
27640 Compare scope — choose what to include
27641 </div>
27642 <div class="scope-options" id="scope-options"></div>
27643 </div>
27644 {% if total_scans > 0 %}
27645 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27646 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27647 <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>
27648 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27649 </div>
27650 </div>
27651 {% endif %}
27652 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27653 <span class="compare-all-label">
27654 <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>
27655 Quick Compare All
27656 </span>
27657 </div>
27658 <div class="table-wrap">
27659 <table id="compare-table">
27660 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27661 <thead>
27662 <tr id="compare-thead">
27663 <th><div class="col-resize-handle"></div></th>
27664 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27665 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27666 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27667 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27668 <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>
27669 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27670 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27671 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27672 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27673 <th>Submodules<div class="col-resize-handle"></div></th>
27674 </tr>
27675 </thead>
27676 <tbody id="compare-tbody">
27677 {% for entry in entries %}
27678 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27679 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27680 data-project="{{ entry.project_label }}"
27681 data-files="{{ entry.files_analyzed }}"
27682 data-code="{{ entry.code_lines }}"
27683 data-comments="{{ entry.comment_lines }}"
27684 data-blank="{{ entry.blank_lines }}"
27685 data-branch="{{ entry.git_branch }}"
27686 data-commit="{{ entry.git_commit }}"
27687 data-submodules="{{ entry.submodule_names_csv }}">
27688 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27689 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27690 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27691 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27692 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27693 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27694 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27695 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27696 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
27697 <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>
27698 <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>
27699 </tr>
27700 {% endfor %}
27701 </tbody>
27702 </table>
27703 </div>
27704 <div class="pagination">
27705 <span class="pagination-info" id="pagination-info"></span>
27706 <div class="pagination-btns" id="pagination-btns"></div>
27707 <div class="flex-row">
27708 <span class="per-page-label">Show</span>
27709 <select class="per-page" id="per-page-sel">
27710 <option value="10">10 per page</option>
27711 <option value="25" selected>25 per page</option>
27712 <option value="50">50 per page</option>
27713 <option value="100">100 per page</option>
27714 </select>
27715 <span class="per-page-label" id="page-range-label"></span>
27716 </div>
27717 </div>
27718 {% endif %}
27719 </section>
27720 </div>
27721
27722 <footer class="site-footer">
27723 local code analysis - metrics, history and reports
27724 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27725 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27726 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27727 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27728 · <a href="/api-docs" rel="noopener">REST API</a>
27729 </footer>
27730
27731 <script nonce="{{ csp_nonce }}">
27732 (function () {
27733 // ── Theme ──────────────────────────────────────────────────────────────
27734 var storageKey = 'oxide-sloc-theme';
27735 var body = document.body;
27736 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27737 var toggle = document.getElementById('theme-toggle');
27738 if (toggle) toggle.addEventListener('click', function () {
27739 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27740 body.classList.toggle('dark-theme', next === 'dark');
27741 try { localStorage.setItem(storageKey, next); } catch(e) {}
27742 });
27743
27744 // ── State ─────────────────────────────────────────────────────────────
27745 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27746 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27747 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27748 window._allCompareRows = allRows;
27749
27750 // ── Stat chips ────────────────────────────────────────────────────────
27751 (function() {
27752 var projects = {}, latestTs = '', latestRow = null;
27753 allRows.forEach(function(r) {
27754 var p = r.dataset.project || ''; if (p) projects[p] = true;
27755 var ts = r.dataset.timestamp || '';
27756 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27757 });
27758 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();}
27759 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>':'');}
27760 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27761 if (latestRow) {
27762 setChipVal('agg-code', latestRow.dataset.code);
27763 setChipVal('agg-files', latestRow.dataset.files);
27764 }
27765 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(); });
27766 })();
27767
27768 // ── Branch filter population ──────────────────────────────────────────
27769 (function() {
27770 var branches = {};
27771 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27772 var sel = document.getElementById('branch-filter');
27773 if (sel) Object.keys(branches).sort().forEach(function(b) {
27774 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27775 });
27776 })();
27777
27778 // ── Filter ────────────────────────────────────────────────────────────
27779 function getFilteredRows() {
27780 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27781 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27782 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27783 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27784 if (branch && (r.dataset.branch || '') !== branch) return false;
27785 return true;
27786 });
27787 }
27788
27789 // ── Pagination ────────────────────────────────────────────────────────
27790 function renderPage() {
27791 var filtered = getFilteredRows();
27792 var total = filtered.length;
27793 var totalPages = Math.max(1, Math.ceil(total / perPage));
27794 currentPage = Math.min(currentPage, totalPages);
27795 var start = (currentPage - 1) * perPage;
27796 var end = Math.min(start + perPage, total);
27797 var shown = {};
27798 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27799 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27800 r.style.display = shown[r.dataset.run] ? '' : 'none';
27801 });
27802 var rl = document.getElementById('page-range-label');
27803 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27804 var info = document.getElementById('pagination-info');
27805 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27806 var btns = document.getElementById('pagination-btns');
27807 if (!btns) return;
27808 btns.innerHTML = '';
27809 function makeBtn(lbl, pg, active, disabled) {
27810 var b = document.createElement('button');
27811 b.className = 'pg-btn' + (active ? ' active' : '');
27812 b.textContent = lbl; b.disabled = disabled;
27813 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27814 return b;
27815 }
27816 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27817 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27818 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27819 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27820 }
27821
27822 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27823 window.applyFilters = function() { currentPage = 1; renderPage(); };
27824
27825 // ── Sorting ───────────────────────────────────────────────────────────
27826 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27827 function doSort(col, type, order) {
27828 var tbody = document.getElementById('compare-tbody');
27829 if (!tbody) return;
27830 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27831 rows.sort(function(a, b) {
27832 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27833 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27834 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27835 return va < vb ? 1 : va > vb ? -1 : 0;
27836 });
27837 rows.forEach(function(r) { tbody.appendChild(r); });
27838 currentPage = 1; renderPage();
27839 }
27840 sortHeaders.forEach(function(th) {
27841 th.addEventListener('click', function(e) {
27842 if (e.target.classList.contains('col-resize-handle')) return;
27843 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27844 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27845 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27846 th.classList.add('sort-' + sortOrder);
27847 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27848 doSort(col, type, sortOrder);
27849 });
27850 });
27851
27852 // Apply default sort (timestamp desc) on initial load
27853 (function() {
27854 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27855 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27856 })();
27857
27858 // ── Column resize ─────────────────────────────────────────────────────
27859 (function() {
27860 var table = document.getElementById('compare-table');
27861 if (!table) return;
27862 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27863 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27864 ths.forEach(function(th, i) {
27865 var handle = th.querySelector('.col-resize-handle');
27866 if (!handle || !cols[i]) return;
27867 var startX, startW;
27868 handle.addEventListener('mousedown', function(e) {
27869 e.stopPropagation(); e.preventDefault();
27870 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27871 handle.classList.add('dragging');
27872 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27873 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27874 document.addEventListener('mousemove', onMove);
27875 document.addEventListener('mouseup', onUp);
27876 });
27877 });
27878 })();
27879
27880 // ── Full-commit hover tooltip ─────────────────────────────────────────
27881 // The commit chips live inside an overflow:auto table wrapper, which would
27882 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27883 // (escaping the scroll container) and follow the cursor. Event delegation
27884 // keeps it working after pagination/sorting re-renders the rows.
27885 (function() {
27886 var tip = document.createElement('div');
27887 tip.className = 'commit-tip';
27888 tip.setAttribute('role', 'tooltip');
27889 document.body.appendChild(tip);
27890 var shown = false;
27891 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27892 function place(e) {
27893 var pad = 14, r = tip.getBoundingClientRect();
27894 var x = e.clientX + pad, y = e.clientY + pad;
27895 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27896 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27897 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27898 }
27899 function hide() { tip.style.display = 'none'; shown = false; }
27900 document.addEventListener('mouseover', function(e) {
27901 var chip = chipFrom(e.target);
27902 if (!chip) return;
27903 var full = chip.getAttribute('data-full-commit');
27904 if (!full) return;
27905 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27906 });
27907 document.addEventListener('mousemove', function(e) {
27908 if (!shown) return;
27909 if (chipFrom(e.target)) place(e); else hide();
27910 });
27911 document.addEventListener('mouseout', function(e) {
27912 if (chipFrom(e.target)) hide();
27913 });
27914 })();
27915
27916 // ── Reset view ────────────────────────────────────────────────────────
27917 window.resetView = function() {
27918 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27919 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27920 sortCol = null; sortOrder = 'asc';
27921 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27922 var tbody = document.getElementById('compare-tbody');
27923 if (tbody) {
27924 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27925 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27926 rows.forEach(function(r) { tbody.appendChild(r); });
27927 }
27928 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27929 var table = document.getElementById('compare-table');
27930 currentPage = 1; renderPage();
27931 currentPage = 1; renderPage();
27932 };
27933
27934 renderPage();
27935 buildCompareAllBar();
27936
27937 // ── Row selection state ───────────────────────────────────────────────
27938 var selected = [];
27939 var lockedProject = null; // project label of first selected scan
27940
27941 function updateCompareBtn() {
27942 var btn = document.getElementById('compare-btn');
27943 var cnt = document.getElementById('sel-count');
27944 if (!btn) return;
27945 btn.disabled = selected.length < 2;
27946 if (cnt) cnt.textContent = selected.length;
27947 }
27948
27949 function applyProjectLock() {
27950 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27951 allRows.forEach(function(r) {
27952 if (lockedProject === null) {
27953 r.classList.remove('row-locked');
27954 } else {
27955 var proj = r.dataset.project || '';
27956 if (proj !== lockedProject) {
27957 r.classList.add('row-locked');
27958 } else {
27959 r.classList.remove('row-locked');
27960 }
27961 }
27962 });
27963 }
27964
27965 function toggleRow(row) {
27966 if (row.classList.contains('row-locked')) return;
27967 var vid = row.dataset.vid || row.dataset.run;
27968 var idx = selected.indexOf(vid);
27969 if (idx >= 0) {
27970 selected.splice(idx, 1);
27971 row.classList.remove('selected');
27972 var b = document.getElementById('badge-' + vid);
27973 if (b) b.textContent = '';
27974 // Release project lock if nothing selected
27975 if (selected.length === 0) lockedProject = null;
27976 } else {
27977 // Set project lock on first selection
27978 if (selected.length === 0) lockedProject = row.dataset.project || null;
27979 selected.push(vid);
27980 row.classList.add('selected');
27981 }
27982 selected.forEach(function(v, i) {
27983 var b = document.getElementById('badge-' + v);
27984 if (b) b.textContent = i + 1;
27985 });
27986 applyProjectLock();
27987 updateCompareBtn();
27988 buildScopePanel();
27989 }
27990
27991 // ── Compare-All bar ───────────────────────────────────────────────────
27992 function buildCompareAllBar() {
27993 var bar = document.getElementById('compare-all-bar');
27994 if (!bar) return;
27995 // Group all rows by project label.
27996 var groups = {};
27997 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27998 // Use all rows from the source data (not just visible).
27999 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28000 // We need ALL rows across all pages, not just the rendered ones.
28001 // Use the underlying allRows array that the pagination JS also uses.
28002 var sourceRows = window._allCompareRows || allRowsAll;
28003 sourceRows.forEach(function(r) {
28004 var proj = r.dataset.project || '';
28005 var vid = r.dataset.vid || r.dataset.run || '';
28006 if (!proj || !vid) return;
28007 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28008 groups[proj].ids.push(vid);
28009 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28010 });
28011 // Build buttons for each project with >= 2 scans.
28012 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28013 if (!keys.length) { bar.style.display = 'none'; return; }
28014 bar.style.display = 'flex';
28015 // Remove old buttons (keep label).
28016 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28017 oldBtns.forEach(function(b) { b.remove(); });
28018 keys.sort();
28019 keys.forEach(function(proj) {
28020 var g = groups[proj];
28021 var btn = document.createElement('button');
28022 btn.className = 'compare-all-btn';
28023 btn.type = 'button';
28024 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28025 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28026 btn.addEventListener('click', function() {
28027 // Sort ids by timestamp (ascending).
28028 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28029 pairs.sort(function(a, b) { return a.ts - b.ts; });
28030 var sorted = pairs.map(function(p) { return p.id; });
28031 if (sorted.length === 2) {
28032 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28033 } else {
28034 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28035 }
28036 });
28037 bar.appendChild(btn);
28038 });
28039 }
28040
28041 // ── Scope panel ───────────────────────────────────────────────────────
28042 var selectedScope = 'all';
28043
28044 function buildScopePanel() {
28045 var panel = document.getElementById('scope-panel');
28046 var opts = document.getElementById('scope-options');
28047 if (!panel || !opts) return;
28048 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28049
28050 // Collect union of submodules from all selected rows.
28051 var allSubs = {};
28052 selected.forEach(function(vid) {
28053 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28054 if (!row) return;
28055 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28056 });
28057 var subList = Object.keys(allSubs).sort();
28058 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28059
28060 panel.classList.remove('hidden');
28061 opts.innerHTML = '';
28062
28063 function makeOption(value, label, title) {
28064 var div = document.createElement('div');
28065 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28066 div.dataset.scopeValue = value;
28067 if (title) div.title = title;
28068 var radio = document.createElement('span');
28069 radio.className = 'scope-option-radio';
28070 var lbl = document.createElement('span');
28071 lbl.textContent = label;
28072 div.appendChild(radio);
28073 div.appendChild(lbl);
28074 div.addEventListener('click', function() {
28075 selectedScope = value;
28076 opts.querySelectorAll('.scope-option').forEach(function(o) {
28077 o.classList.toggle('selected', o.dataset.scopeValue === value);
28078 });
28079 });
28080 return div;
28081 }
28082
28083 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28084 var sep = document.createElement('span');
28085 sep.className = 'scope-option-sep';
28086 opts.appendChild(sep);
28087 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28088 subList.forEach(function(s) {
28089 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28090 });
28091 }
28092
28093 function doCompare() {
28094 if (selected.length < 2) return;
28095 if (selected.length === 2) {
28096 // Two-scan delta (existing flow with scope support).
28097 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28098 if (selectedScope === 'super') url += '&scope=super';
28099 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28100 window.location.href = url;
28101 } else {
28102 // Multi-scan timeline (N >= 3) — pass scope params too.
28103 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28104 if (selectedScope === 'super') url += '&scope=super';
28105 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28106 window.location.href = url;
28107 }
28108 }
28109
28110 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28111 var cbtn = document.getElementById('compare-btn');
28112 if (cbtn) cbtn.addEventListener('click', doCompare);
28113 var pfEl = document.getElementById('project-filter');
28114 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28115 var bfEl = document.getElementById('branch-filter');
28116 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28117 var rvBtn = document.getElementById('reset-view-btn');
28118 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28119 var ppSel = document.getElementById('per-page-sel');
28120 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28121
28122 var cmpTbody = document.getElementById('compare-tbody');
28123 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28124 var row = e.target.closest('.compare-row');
28125 if (row) toggleRow(row);
28126 });
28127
28128 (function randomizeWatermarks() {
28129 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28130 if (!wms.length) return;
28131 var placed = [];
28132 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;}
28133 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];}
28134 var half=Math.floor(wms.length/2);
28135 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;});
28136 })();
28137
28138 (function spawnCodeParticles() {
28139 var container = document.getElementById('code-particles');
28140 if (!container) return;
28141 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'];
28142 for (var i = 0; i < 38; i++) {
28143 (function(idx) {
28144 var el = document.createElement('span');
28145 el.className = 'code-particle';
28146 el.textContent = snippets[idx % snippets.length];
28147 var left = Math.random() * 94 + 2;
28148 var top = Math.random() * 88 + 6;
28149 var dur = (Math.random() * 10 + 9).toFixed(1);
28150 var delay = (Math.random() * 18).toFixed(1);
28151 var rot = (Math.random() * 26 - 13).toFixed(1);
28152 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28153 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';
28154 container.appendChild(el);
28155 })(i);
28156 }
28157 })();
28158
28159 // ── Watched folder picker ─────────────────────────────────────────────
28160 (function() {
28161 var btn = document.getElementById('add-watched-btn');
28162 if (!btn) return;
28163 btn.addEventListener('click', function() {
28164 fetch('/pick-directory?kind=reports')
28165 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28166 .then(function(data) {
28167 if (!data.cancelled && data.selected_path) {
28168 var form = document.createElement('form');
28169 form.method = 'POST';
28170 form.action = '/watched-dirs/add';
28171 var ri = document.createElement('input');
28172 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28173 var fi = document.createElement('input');
28174 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28175 form.appendChild(ri); form.appendChild(fi);
28176 document.body.appendChild(form);
28177 form.submit();
28178 }
28179 })
28180 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28181 });
28182 })();
28183
28184 // ── Submodule chip truncation ─────────────────────────────────────────
28185 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28186 var chips = cell.querySelectorAll('.submod-chip');
28187 var MAX = 4;
28188 if (chips.length <= MAX) return;
28189 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28190 var badge = document.createElement('span');
28191 badge.className = 'submod-overflow-badge';
28192 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28193 badge.textContent = '+' + (chips.length - MAX) + ' more';
28194 cell.appendChild(badge);
28195 cell.style.maxHeight = 'none';
28196 });
28197 })();
28198 </script>
28199 <script nonce="{{ csp_nonce }}">
28200 (function(){
28201 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'}];
28202 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);});}
28203 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28204 function init(){
28205 var btn=document.getElementById('settings-btn');if(!btn)return;
28206 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28207 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>';
28208 document.body.appendChild(m);
28209 var g=document.getElementById('scheme-grid');
28210 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);});
28211 var cl=document.getElementById('settings-close');
28212 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);
28213 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');});
28214 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28215 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28216 }
28217 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28218 }());
28219 </script>
28220 <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]';
28221 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;}
28222 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>
28223</body>
28224</html>
28225"##,
28226 ext = "html"
28227)]
28228struct CompareSelectTemplate {
28229 version: &'static str,
28230 entries: Vec<HistoryEntryRow>,
28231 total_scans: usize,
28232 watched_dirs: Vec<String>,
28233 csp_nonce: String,
28234 server_mode: bool,
28235}
28236
28237#[derive(Template)]
28240#[template(
28241 source = r##"
28242<!doctype html>
28243<html lang="en">
28244<head>
28245 <meta charset="utf-8">
28246 <meta name="viewport" content="width=device-width, initial-scale=1">
28247 <title>OxideSLOC | Scan Delta</title>
28248 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28249 <style nonce="{{ csp_nonce }}">
28250 :root {
28251 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28252 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28253 --nav:#283790; --nav-2:#013e6b;
28254 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28255 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28256 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28257 }
28258 body.dark-theme {
28259 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28260 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28261 }
28262 *{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;}
28263 .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);}
28264 .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;}
28265 .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));}
28266 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28267 .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;}
28268 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28269 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28270 @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; } }
28271 .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;}
28272 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28273 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28274 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28275 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28276 .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;}
28277 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28278 .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);}
28279 .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;}
28280 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28281 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28282 .settings-modal-body{padding:14px 16px 16px;}
28283 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28284 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28285 .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;}
28286 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28287 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28288 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28289 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28290 .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;}
28291 .tz-select:focus{border-color:var(--oxide);}
28292 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28293 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28294 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28295 .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;}
28296 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28297 .hero-body{display:block;}
28298 .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
28299 .btn-back:hover{background:var(--line);}
28300 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28301 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28302 .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;}
28303 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28304 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;}
28305 .muted{color:var(--muted);font-size:14px;}
28306 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28307 .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;}
28308 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28309 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28310 .vpill-arrow{font-size:20px;color:var(--muted);}
28311 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28312 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28313 .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;}
28314 .delta-card.delta-card-wide{padding:22px 24px;}
28315 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28316 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28317 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28318 .delta-card-from{font-size:15px;color:var(--muted);}
28319 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28320 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28321 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28322 .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%;}
28323 .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;}
28324 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28325 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28326 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28327 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28328 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28329 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28330 .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;}
28331 .meta-card-commit:hover{color:var(--oxide);}
28332 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28333 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28334 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28335 .meta-value{color:var(--text);font-size:13px;}
28336 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28337 .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;}
28338 .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);}
28339 .delta-card:hover .dc-tip{display:block;}
28340 .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;}
28341 .export-btn:hover{background:var(--line);}
28342 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28343 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28344 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28345 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28346 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28347 .delta-card-change.zero{color:var(--muted);background:transparent;}
28348 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28349 .delta-card-pct.pos{color:var(--pos);}
28350 .delta-card-pct.neg{color:var(--neg);}
28351 .delta-card-pct.zero{color:var(--muted);}
28352 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28353 .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;}
28354 .insight-card.insight-flag{border-color:var(--oxide);}
28355 .insight-card:hover .dc-tip{display:block;}
28356 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28357 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28358 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28359 .insight-label.flag{color:var(--oxide);}
28360 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28361 .insight-val.pos{color:var(--pos);}
28362 .insight-val.neg{color:var(--neg);}
28363 .insight-val.high{color:#c0392a;}
28364 .insight-val.med{color:#926000;}
28365 .insight-val.low{color:var(--pos);}
28366 body.dark-theme .insight-val.high{color:#ff6b6b;}
28367 body.dark-theme .insight-val.med{color:#f0c060;}
28368 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28369 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28370 .fc-row{display:flex;align-items:center;gap:8px;}
28371 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28372 .fc-label{color:var(--muted);}
28373 .fc-modified .fc-count{color:#926000;}
28374 .fc-added .fc-count{color:var(--pos);}
28375 .fc-removed .fc-count{color:var(--neg);}
28376 .fc-unchanged .fc-count{color:var(--muted);}
28377 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28378 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28379 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28380 .chip.modified{background:#fff2d8;color:#926000;}
28381 .chip.added{background:#e8f5ed;color:#1a8f47;}
28382 .chip.removed{background:#fdeaea;color:#b33b3b;}
28383 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28384 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28385 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28386 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28387 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28388 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28389 .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;}
28390 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28391 .tab-btn:hover:not(.active){background:var(--line);}
28392 .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;}
28393 .btn-reset:hover{background:var(--line);}
28394 .table-wrap{width:100%;overflow-x:auto;}
28395 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28396 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);}
28397 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28398 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28399 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28400 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28401 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28402 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28403 tr:last-child td{border-bottom:none;}
28404 tr:hover td{background:var(--surface-2);}
28405 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28406 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28407 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28408 /* Fixed layout: column widths come from the colgroup, not from scanning every
28409 row. With auto layout a large file matrix forces the browser to re-measure
28410 all cells on each reflow, which freezes the page during sort/resize. */
28411 #delta-table{table-layout:fixed;}
28412 #delta-table col:nth-child(1){width:32%;}
28413 #delta-table col:nth-child(2){width:11%;}
28414 #delta-table col:nth-child(3){width:11%;}
28415 #delta-table col:nth-child(4){width:16%;}
28416 #delta-table col:nth-child(5){width:10%;}
28417 #delta-table col:nth-child(6){width:10%;}
28418 #delta-table col:nth-child(7){width:10%;}
28419 tr.row-added td{background:rgba(26,143,71,0.04);}
28420 tr.row-removed td{background:rgba(179,59,59,0.06);}
28421 tr.row-modified td{background:rgba(146,96,0,0.04);}
28422 tr.row-unchanged td{color:var(--muted);}
28423 tr.row-unchanged .status-badge{opacity:.65;}
28424 .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;}
28425 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28426 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28427 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28428 .status-badge.modified{background:#fff2d8;color:#926000;}
28429 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28430 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28431 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28432 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28433 .delta-val{font-weight:700;}
28434 .delta-val.pos{color:var(--pos);}
28435 .delta-val.neg{color:var(--neg);}
28436 .delta-val.zero{color:var(--muted);}
28437 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28438 .from-to strong{color:var(--text);font-weight:700;}
28439 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28440 .from-to .ft-absent{color:var(--muted);font-weight:600;}
28441 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28442 .site-footer a{color:var(--muted);}
28443 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;}
28444 body.pdf-mode{background:#fff!important;}
28445 body.pdf-mode .page{padding:4px 6px 4px!important;}
28446 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28447 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28448 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28449 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28450 .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;}
28451 .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;}
28452 .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;}
28453 @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));}}
28454 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28455 .path-link:hover{color:var(--oxide-2);}
28456 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28457 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28458 a.vpill-id:hover{color:var(--oxide);}
28459 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28460 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28461 .pagination-info{font-size:13px;color:var(--muted);}
28462 .pagination-btns{display:flex;gap:6px;}
28463 .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;}
28464 .pg-btn:hover:not(:disabled){background:var(--line);}
28465 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28466 .pg-btn:disabled{opacity:.35;cursor:default;}
28467 .per-page-label{font-size:13px;color:var(--muted);}
28468 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;}
28469 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28470 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28471 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28472 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28473 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28474 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28475 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28476 .tab-btn.tab-unchanged{color:var(--muted);}
28477 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28478 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28479 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28480 .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;}
28481 .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;}
28482 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28483 .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;}
28484 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28485 .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;}
28486 .submod-scope-btn:hover{background:var(--line);}
28487 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28488 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28489 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28490 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28491 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28492 body.dark-theme .ic-card{background:var(--surface-2);}
28493 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28494 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28495 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28496 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28497 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28498 .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);}
28499 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28500 .ic-card-h2-row .ic-card-h2{margin:0;}
28501 .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;}
28502 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28503 .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;}
28504 .ic-svg-modal-ov.open{display:flex;}
28505 .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);}
28506 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28507 .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);}
28508 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28509 .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;}
28510 .ic-svg-modal-close:hover{background:var(--line);}
28511 .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;}
28512 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28513 .chart-metric-btn:hover:not(.active){background:var(--line);}
28514 .chart-wrap{width:100%;overflow-x:auto;}
28515 #cmp-tl-svg{display:block;width:100%;}
28516 .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);}
28517 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28518 #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;}
28519 </style>
28520</head>
28521<body>
28522 {{ loading_overlay|safe }}
28523 <div class="background-watermarks" aria-hidden="true">
28524 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28525 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28526 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28527 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28528 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28529 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28530 </div>
28531 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28532 <div class="top-nav">
28533 <div class="top-nav-inner">
28534 <a class="brand" href="/">
28535 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28536 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28537 </a>
28538 <div class="nav-right">
28539 <a class="nav-pill" href="/">Home</a>
28540 <div class="nav-dropdown">
28541 <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>
28542 <div class="nav-dropdown-menu">
28543 <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>
28544 </div>
28545 </div>
28546 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28547 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28548 <div class="nav-dropdown">
28549 <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>
28550 <div class="nav-dropdown-menu">
28551 <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>
28552 </div>
28553 </div>
28554 <div class="server-status-wrap" id="server-status-wrap">
28555 <div class="nav-pill server-online-pill" id="server-status-pill">
28556 <span class="status-dot" id="status-dot"></span>
28557 <span id="server-status-label">Server</span>
28558 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28559 </div>
28560 <div class="server-status-tip">
28561 OxideSLOC is running — accessible on your network.
28562 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28563 </div>
28564 </div>
28565 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28566 <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>
28567 </button>
28568 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28569 <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>
28570 <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>
28571 </button>
28572 </div>
28573 </div>
28574 </div>
28575
28576 <div class="page">
28577 <section class="hero">
28578 <div class="hero-header">
28579 <div>
28580 <h1 class="delta-title">Scan Delta</h1>
28581 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28582 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28583 {% if let Some(sub) = active_submodule %}
28584 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28585 {% else if super_scope_active %}
28586 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28587 {% else %}
28588 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28589 {% endif %}
28590 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28591 </div>
28592 </div>
28593 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28594 <a class="btn-back" href="/compare-scans">
28595 <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>
28596 Compare Scans
28597 </a>
28598 <div class="export-group" style="margin-top:12px;">
28599 <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>
28600 <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>
28601 </div>
28602 </div>
28603 </div>
28604 {% if has_any_submodule_data %}
28605 <div class="submod-scope-bar">
28606 <span class="submod-scope-label">
28607 <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>
28608 Scope:
28609 </span>
28610 <div class="submod-scope-divider"></div>
28611 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28612 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
28613 title="All files — super-repo and all submodules combined">Full scan</a>
28614 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28615 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
28616 title="Only files that are not part of any submodule">Super-repo only</a>
28617 {% for sub in submodule_options %}
28618 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28619 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
28620 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28621 {% endfor %}
28622 </div>
28623 {% endif %}
28624 <div class="hero-body">
28625 <div class="meta-strip">
28626 <div class="delta-card delta-card-meta">
28627 <div class="meta-card-header">
28628 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28629 <div class="meta-card-project-col">
28630 <div class="meta-card-project">{{ project_name }}</div>
28631 {% if has_any_submodule_data %}
28632 {% if let Some(sub) = active_submodule %}
28633 <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>
28634 {% else if super_scope_active %}
28635 <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>
28636 {% else %}
28637 <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>
28638 {% endif %}
28639 {% endif %}
28640 </div>
28641 </div>
28642 {% if !baseline_git_commit.is_empty() %}
28643 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28644 {% else %}
28645 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28646 {% endif %}
28647 <div class="meta-card-rows">
28648 <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>
28649 <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>
28650 <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>
28651 <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>
28652 {% if let Some(tags) = baseline_git_tags %}
28653 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28654 {% endif %}
28655 </div>
28656 </div>
28657 <div class="delta-card delta-card-meta">
28658 <div class="meta-card-header">
28659 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28660 <div class="meta-card-project-col">
28661 <div class="meta-card-project">{{ project_name }}</div>
28662 {% if has_any_submodule_data %}
28663 {% if let Some(sub) = active_submodule %}
28664 <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>
28665 {% else if super_scope_active %}
28666 <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>
28667 {% else %}
28668 <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>
28669 {% endif %}
28670 {% endif %}
28671 </div>
28672 </div>
28673 {% if !current_git_commit.is_empty() %}
28674 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28675 {% else %}
28676 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28677 {% endif %}
28678 <div class="meta-card-rows">
28679 <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>
28680 <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>
28681 <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>
28682 <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>
28683 {% if let Some(tags) = current_git_tags %}
28684 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28685 {% endif %}
28686 </div>
28687 </div>
28688 </div>
28689 <div class="delta-strip">
28690 <div class="delta-card">
28691 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28692 <div class="delta-card-label">Code lines</div>
28693 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28694 <div class="delta-card-to">{{ current_code_fmt }}</div>
28695 {% 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>
28696 {% 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>
28697 {% else %}<div class="delta-card-pct zero">±0%</div>
28698 {% endif %}
28699 </div>
28700 <div class="delta-card">
28701 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28702 <div class="delta-card-label">Files analyzed</div>
28703 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28704 <div class="delta-card-to">{{ current_files_fmt }}</div>
28705 {% 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>
28706 {% 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>
28707 {% else %}<div class="delta-card-pct zero">±0%</div>
28708 {% endif %}
28709 </div>
28710 <div class="delta-card">
28711 <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>
28712 <div class="delta-card-label">Comment lines</div>
28713 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28714 <div class="delta-card-to">{{ current_comments_fmt }}</div>
28715 {% 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>
28716 {% 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>
28717 {% else %}<div class="delta-card-pct zero">±0%</div>
28718 {% endif %}
28719 </div>
28720 {{ coverage_delta_card|safe }}
28721 <div class="delta-card delta-card-wide">
28722 <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>
28723 <div class="delta-card-label">File changes</div>
28724 <div class="file-changes-grid">
28725 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28726 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28727 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28728 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28729 </div>
28730 </div>
28731 </div>
28732 <div class="insights-panel">
28733 <div class="insight-card">
28734 <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>
28735 <div class="insight-label">Lines Added</div>
28736 <div class="insight-val pos">+{{ code_lines_added }}</div>
28737 <div class="insight-sub">New or grown source lines</div>
28738 </div>
28739 <div class="insight-card">
28740 <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>
28741 <div class="insight-label">Lines Removed</div>
28742 <div class="insight-val neg">−{{ code_lines_removed }}</div>
28743 <div class="insight-sub">Deleted or shrunk source lines</div>
28744 </div>
28745 <div class="insight-card">
28746 <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>
28747 <div class="insight-label">Churn Rate</div>
28748 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28749 <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>
28750 </div>
28751 {% if scope_flag %}
28752 <div class="insight-card insight-flag">
28753 <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>
28754 <div class="insight-label flag">Scope Signal</div>
28755 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28756 <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>
28757 </div>
28758 {% endif %}
28759 </div>
28760 </div>
28761 </section>
28762
28763 <section class="panel" id="inline-charts-section">
28764 <div class="panel-title">Scan Delta Charts</div>
28765 <div class="ic-grid">
28766 <div class="ic-card" style="grid-column:span 2">
28767 <div class="ic-card-h2-row">
28768 <span class="ic-card-h2">Timeline</span>
28769 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28770 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28771 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28772 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28773 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28774 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28775 </div>
28776 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
28777 </div>
28778 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28779 </div>
28780 <div class="ic-card">
28781 <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>
28782 <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>
28783 <div id="ic-c1"></div>
28784 </div>
28785 <div class="ic-card" id="ic-lang-card">
28786 <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>
28787 <div id="ic-c3"></div>
28788 </div>
28789 <div class="ic-card">
28790 <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>
28791 <div id="ic-c2"></div>
28792 </div>
28793 <div class="ic-card">
28794 <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>
28795 <div id="ic-c4"></div>
28796 </div>
28797 </div>
28798 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28799 <div class="ic-svg-modal">
28800 <div class="ic-svg-modal-hdr">
28801 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28802 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
28803 </div>
28804 <div id="ic-svg-modal-body"></div>
28805 </div>
28806 </div>
28807 </section>
28808
28809 <section class="panel">
28810 <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>
28811 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28812 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28813 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28814 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28815 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28816 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28817 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28818 </div>
28819 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28820 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
28821 <div class="export-group">
28822 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
28823 <button type="button" class="export-btn" id="delta-csv-btn">
28824 <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>
28825 CSV
28826 </button>
28827 <button type="button" class="export-btn" id="delta-xls-btn">
28828 <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>
28829 Excel
28830 </button>
28831 </div>
28832 </div>
28833 </div>
28834
28835 <div class="table-wrap">
28836 <table id="delta-table">
28837 <colgroup>
28838 <col>
28839 <col>
28840 <col>
28841 <col>
28842 <col>
28843 <col>
28844 <col>
28845 </colgroup>
28846 <thead>
28847 <tr id="delta-thead">
28848 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28849 <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>
28850 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28851 <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>
28852 <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>
28853 <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>
28854 <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>
28855 </tr>
28856 </thead>
28857 <tbody id="delta-tbody">
28858 {% for row in file_rows %}
28859 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28860 data-path="{{ row.relative_path }}"
28861 data-language="{{ row.language }}"
28862 data-baseline-code="{{ row.baseline_code }}"
28863 data-current-code="{{ row.current_code }}"
28864 data-code-delta="{{ row.code_delta_str }}"
28865 data-comment-delta="{{ row.comment_delta_str }}"
28866 data-total-delta="{{ row.total_delta_str }}"
28867 data-orig-idx="">
28868 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28869 <td class="hide-sm">{{ row.language }}</td>
28870 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28871 <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>
28872 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28873 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28874 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28875 </tr>
28876 {% endfor %}
28877 </tbody>
28878 </table>
28879 </div>
28880 <div class="pagination">
28881 <span class="pagination-info" id="pg-range-label"></span>
28882 <div class="pagination-btns" id="pg-btns"></div>
28883 <div class="flex-row">
28884 <span class="per-page-label">Show</span>
28885 <select class="per-page" id="per-page-sel">
28886 <option value="10">10 per page</option>
28887 <option value="25" selected>25 per page</option>
28888 <option value="50">50 per page</option>
28889 <option value="100">100 per page</option>
28890 </select>
28891 </div>
28892 </div>
28893 </section>
28894 </div>
28895
28896 <div id="ic-tt"></div>
28897
28898 <footer class="site-footer">
28899 local code analysis - metrics, history and reports
28900 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} \u2014 Mode: Local</em>
28901 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28902 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28903 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28904 · <a href="/api-docs" rel="noopener">REST API</a>
28905 </footer>
28906
28907 <script nonce="{{ csp_nonce }}">
28908 (function () {
28909 var storageKey = 'oxide-sloc-theme';
28910 var body = document.body;
28911 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28912 var toggle = document.getElementById('theme-toggle');
28913 if (toggle) toggle.addEventListener('click', function () {
28914 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28915 body.classList.toggle('dark-theme', next === 'dark');
28916 try { localStorage.setItem(storageKey, next); } catch(e) {}
28917 });
28918
28919 (function randomizeWatermarks() {
28920 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28921 if (!wms.length) return;
28922 var placed = [];
28923 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;}
28924 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];}
28925 var half=Math.floor(wms.length/2);
28926 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;});
28927 })();
28928
28929 (function spawnCodeParticles() {
28930 var container = document.getElementById('code-particles');
28931 if (!container) return;
28932 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'];
28933 for (var i = 0; i < 38; i++) {
28934 (function(idx) {
28935 var el = document.createElement('span');
28936 el.className = 'code-particle';
28937 el.textContent = snippets[idx % snippets.length];
28938 var left = Math.random() * 94 + 2;
28939 var top = Math.random() * 88 + 6;
28940 var dur = (Math.random() * 10 + 9).toFixed(1);
28941 var delay = (Math.random() * 18).toFixed(1);
28942 var rot = (Math.random() * 26 - 13).toFixed(1);
28943 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28944 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';
28945 container.appendChild(el);
28946 })(i);
28947 }
28948 })();
28949 })();
28950
28951 var activeStatusFilter = 'all';
28952 var deltaPerPage = 25, deltaCurrPage = 1;
28953
28954 function openFolder(path) {
28955 fetch('/open-path?path=' + encodeURIComponent(path))
28956 .then(function (r) { return r.json(); })
28957 .then(function (d) {
28958 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
28959 })
28960 .catch(function () {});
28961 }
28962
28963 // \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
28964 // The server renders every row once; we lift them into a plain-data array and
28965 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
28966 // filtering run on the array (no DOM churn) and each render rebuilds just one
28967 // page (~25 rows). This keeps every interaction O(page) instead of O(all
28968 // files): a 28k-row table previously re-touched every node on each click
28969 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
28970 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
28971
28972 function parseDeltaNum(str) {
28973 if (!str || str === '\u2014') return 0;
28974 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
28975 }
28976
28977 function captureDelta() {
28978 var tbody = document.getElementById('delta-tbody');
28979 if (!tbody) return;
28980 var rows = tbody.querySelectorAll('.delta-row');
28981 for (var i = 0; i < rows.length; i++) {
28982 var r = rows[i];
28983 DELTA.push({
28984 h: r.innerHTML,
28985 cls: r.className,
28986 path: r.getAttribute('data-path') || '',
28987 lang: r.getAttribute('data-language') || '',
28988 status: r.getAttribute('data-status') || '',
28989 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
28990 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
28991 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
28992 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
28993 td: parseDeltaNum(r.getAttribute('data-total-delta')),
28994 bcs: r.getAttribute('data-baseline-code') || '',
28995 ccs: r.getAttribute('data-current-code') || '',
28996 cds: r.getAttribute('data-code-delta') || '',
28997 cmds: r.getAttribute('data-comment-delta') || '',
28998 tds: r.getAttribute('data-total-delta') || ''
28999 });
29000 }
29001 tbody.innerHTML = '';
29002 }
29003
29004 function applyDeltaQuery() {
29005 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29006 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29007 if (sortCol) {
29008 var asc = sortOrder === 'asc';
29009 v.sort(function(a, b) {
29010 var va, vb;
29011 if (sortCol === 'path') { va = a.path; vb = b.path; }
29012 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29013 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29014 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29015 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29016 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29017 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29018 else { return 0; }
29019 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29020 return va < vb ? 1 : va > vb ? -1 : 0;
29021 });
29022 }
29023 _deltaView = v;
29024 deltaCurrPage = 1;
29025 renderDeltaPage();
29026 }
29027
29028 function renderDeltaPage() {
29029 var total = _deltaView.length;
29030 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29031 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29032 if (deltaCurrPage < 1) deltaCurrPage = 1;
29033 var start = (deltaCurrPage - 1) * deltaPerPage;
29034 var end = Math.min(start + deltaPerPage, total);
29035 var tbody = document.getElementById('delta-tbody');
29036 if (tbody) {
29037 var html = '';
29038 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29039 tbody.innerHTML = html;
29040 }
29041 var rl = document.getElementById('pg-range-label');
29042 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29043 var btns = document.getElementById('pg-btns');
29044 if (!btns) return;
29045 btns.innerHTML = '';
29046 if (totalPages <= 1) return;
29047 function makeBtn(lbl, pg, active, disabled) {
29048 var b = document.createElement('button');
29049 b.className = 'pg-btn' + (active ? ' active' : '');
29050 b.textContent = lbl; b.disabled = disabled;
29051 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29052 return b;
29053 }
29054 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29055 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29056 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29057 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29058 }
29059
29060 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29061
29062 function filterRows(status, btn) {
29063 activeStatusFilter = status;
29064 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29065 b.classList.remove('active');
29066 });
29067 if (btn) btn.classList.add('active');
29068 applyDeltaQuery();
29069 }
29070
29071 // ── Sorting ──────────────────────────────────────────────────────────────
29072 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29073 sortHeaders.forEach(function(th) {
29074 th.addEventListener('click', function(e) {
29075 if (e.target.classList.contains('col-resize-handle')) return;
29076 var col = th.dataset.sortCol;
29077 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29078 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29079 th.classList.add('sort-' + sortOrder);
29080 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29081 applyDeltaQuery();
29082 });
29083 });
29084
29085 // ── Column resize ─────────────────────────────────────────────────────────
29086 (function() {
29087 var table = document.getElementById('delta-table');
29088 if (!table) return;
29089 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29090 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29091 ths.forEach(function(th, i) {
29092 var handle = th.querySelector('.col-resize-handle');
29093 if (!handle || !cols[i]) return;
29094 handle.addEventListener('mousedown', function(e) {
29095 e.stopPropagation(); e.preventDefault();
29096 // Lock every column to its current rendered px width and size the table
29097 // to the column total. With table-layout:fixed + width:100% the table is
29098 // pinned to the container, so widening one <col> only rebalances the rest
29099 // and the drag looks inert; pinning px widths lets the column actually
29100 // grow while the wrapper (overflow-x:auto) scrolls.
29101 var startTableW = 0;
29102 for (var k = 0; k < ths.length; k++) {
29103 if (!cols[k]) continue;
29104 var w = ths[k].getBoundingClientRect().width;
29105 cols[k].style.width = w + 'px';
29106 startTableW += w;
29107 }
29108 table.style.width = startTableW + 'px';
29109 var startX = e.clientX;
29110 var startW = ths[i].getBoundingClientRect().width;
29111 handle.classList.add('dragging');
29112 function onMove(ev) {
29113 var newW = Math.max(40, startW + ev.clientX - startX);
29114 cols[i].style.width = newW + 'px';
29115 table.style.width = (startTableW + (newW - startW)) + 'px';
29116 }
29117 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29118 document.addEventListener('mousemove', onMove);
29119 document.addEventListener('mouseup', onUp);
29120 });
29121 });
29122 })();
29123
29124 // ── Reset ─────────────────────────────────────────────────────────────────
29125 window.resetDeltaTable = function() {
29126 sortCol = null; sortOrder = 'asc';
29127 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29128 var table = document.getElementById('delta-table');
29129 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29130 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29131 activeStatusFilter = 'all';
29132 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29133 var allBtn = document.querySelector('.tab-btn');
29134 if (allBtn) allBtn.classList.add('active');
29135 applyDeltaQuery();
29136 };
29137
29138 // Compact number formatter (shared by the delta table; charts define their own locally)
29139 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();}
29140 function fmtFull(n){return Number(n).toLocaleString();}
29141
29142 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29143 function fmtFromTo() {
29144 var tbody = document.getElementById('delta-tbody');
29145 if (!tbody) return;
29146 tbody.querySelectorAll('.delta-row').forEach(function(row) {
29147 var status = row.dataset.status || '';
29148 var ft = row.querySelector('.from-to');
29149 if (!ft) return;
29150 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29151 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29152 var strongs = ft.querySelectorAll('strong');
29153 // Apply fmt() to non-absent strong values
29154 strongs.forEach(function(el) {
29155 var n = parseInt(el.textContent, 10);
29156 if (!isNaN(n)) el.textContent = fmtFull(n);
29157 });
29158 // Safety: force dash for genuinely absent sides
29159 if (status === 'added' && bv === 0) {
29160 var bs = ft.querySelector('strong:first-of-type');
29161 if (bs && bs.textContent === '0') {
29162 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29163 }
29164 }
29165 if (status === 'removed' && cv === 0) {
29166 var cs = ft.querySelector('strong:last-of-type');
29167 if (cs && cs.textContent === '0') {
29168 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29169 }
29170 }
29171 });
29172 }
29173 // Initialize: format the server-rendered rows, lift them into the data model
29174 // (which also clears the DOM), then render only the first page.
29175 fmtFromTo();
29176 captureDelta();
29177 applyDeltaQuery();
29178
29179 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29180 (function() {
29181 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29182 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29183 });
29184 var resetBtn = document.getElementById('delta-reset-btn');
29185 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29186 var csvBtn = document.getElementById('delta-csv-btn');
29187 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29188 var xlsBtn = document.getElementById('delta-xls-btn');
29189 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29190 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29191 function sdFetchUri(path) {
29192 return fetch(path).then(function(r){return r.blob();}).then(function(b){
29193 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29194 }).catch(function(){return '';});
29195 }
29196 function sdInlineImgs(html, cb) {
29197 var paths=[], seen={};
29198 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29199 if(!paths.length){cb(html);return;}
29200 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29201 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29202 .catch(function(){cb(html);});
29203 }
29204 function buildFullPageHtml(pdfMode) {
29205 if(pdfMode) document.body.classList.add('pdf-mode');
29206 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29207 renderDeltaPage();
29208 var html = document.documentElement.outerHTML;
29209 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29210 if(pdfMode) document.body.classList.remove('pdf-mode');
29211 return html;
29212 }
29213 var chartsBtn = document.getElementById('delta-charts-btn');
29214 if (chartsBtn) chartsBtn.addEventListener('click', function() {
29215 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29216 sdInlineImgs(buildFullPageHtml(false), function(html) {
29217 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29218 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29219 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29220 btn.disabled=false;btn.innerHTML=orig;
29221 });
29222 });
29223 var pageHtmlBtn = document.getElementById('page-export-html-btn');
29224 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29225 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29226 sdInlineImgs(buildFullPageHtml(false), function(html) {
29227 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29228 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29229 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29230 btn.disabled=false;btn.innerHTML=orig;
29231 });
29232 });
29233 // PDF export — clean document-style report, not a web page screenshot
29234 function buildDeltaPdfHtml() {
29235 var sd=_sd, dr=getDeltaExportRows();
29236 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29237 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+'%';}
29238 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29239 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29240 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29241 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29242 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29243 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29244 function fmtN(n){return Number(n).toLocaleString();}
29245 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29246 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>';}
29247 var lm={};
29248 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;});
29249 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29250 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29251 // The header/footer flow in normal document order (NOT position:fixed).
29252 // A fixed header repeats on every printed page in Chromium and overlaps
29253 // the content beneath it — silently swallowing the first few table rows of
29254 // pages 2+ and clipping the summary cards on page 1. Letting the header
29255 // flow once at the top and relying on the table's <thead> (which Chromium
29256 // repeats per page) keeps every row visible. `.body` keeps a small inset
29257 // so nothing bleeds to the sheet edge.
29258 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29259 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29260 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29261 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29262 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29263 '.ph-brand em{color:#c45c10;font-style:normal;}'+
29264 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29265 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29266 '.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;}'+
29267 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29268 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29269 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29270 '.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;}'+
29271 '.body{padding:12px 18px 0;}'+
29272 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29273 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29274 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29275 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29276 '.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;}'+
29277 '.meta>div{flex:1 1 0;}'+
29278 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29279 '.sec{margin-bottom:10px;}'+
29280 '.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;}'+
29281 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29282 '.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;}'+
29283 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29284 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29285 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29286 '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;}'+
29287 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29288 'tr:nth-child(even) td{background:#faf8f6;}'+
29289 '.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;}'+
29290 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29291 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29292 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29293 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29294 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29295 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29296 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29297 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29298 '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29299 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29300 '.fcc-n{font-size:18px;font-weight:900;}'+
29301 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29302 var fileRows=dchg.map(function(r){
29303 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29304 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29305 '<td style="'+ss+'">'+esc(st)+'</td>'+
29306 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29307 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29308 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29309 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29310 var more='';
29311 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('');
29312 var extraCards='';
29313 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>';}
29314 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>';}
29315 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29316 '<div class="pdf-header">'+
29317 '<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>'+
29318 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29319 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29320 '</div>'+
29321 '<div class="body">'+
29322 '<div class="sec"><p class="sh">Summary Metrics</p>'+
29323 '<div class="msec">'+
29324 '<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>'+
29325 '<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>'+
29326 '<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>'+
29327 '<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>'+
29328 '<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>'+
29329 '<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>'+
29330 extraCards+'</div></div>'+
29331 '<div class="sec"><p class="sh">File Changes</p>'+
29332 '<div class="fcsec">'+
29333 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29334 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29335 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29336 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29337 '</div></div>'+
29338 (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>':'')+
29339 '<div class="sec">'+
29340 '<table><thead>'+
29341 '<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>'+
29342 '<tr><th>File</th><th>Language</th><th>Status</th>'+
29343 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29344 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29345 '</div>'+
29346 '<div class="rfoot">'+
29347 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29348 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29349 '</div>'+
29350 '</body></html>';
29351 }
29352 function doDeltaPdf(btn) {
29353 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29354 }
29355 var pdfBtn = document.getElementById('delta-pdf-btn');
29356 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29357 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29358 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29359 if (location.protocol === 'file:') {
29360 [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'; } });
29361 [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'; } });
29362 }
29363 var ppSel = document.getElementById('per-page-sel');
29364 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29365 var pathLink = document.getElementById('project-path-link');
29366 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29367 })();
29368
29369 // ── Export helpers ────────────────────────────────────────────────────────
29370 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
29371 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29372 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);}
29373 function slocMakeXlsx(fname,sd,dr){
29374 var enc=new TextEncoder();
29375 // CRC-32 table
29376 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;}
29377 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;}
29378 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29379 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29380 // Shared string table
29381 var ss=[],si={};
29382 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29383 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29384 // Worksheet builder — each WS() call gets its own row counter R
29385 function WS(){
29386 var R=0,buf=[];
29387 function cl(c){return String.fromCharCode(65+c);}
29388 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29389 '<v>'+S(v)+'</v></c>';}
29390 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29391 (st?' s="'+st+'"':'')+'>'+
29392 '<v>'+(+v)+'</v></c>';}
29393 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29394 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29395 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29396 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29397 '<sheetFormatPr defaultRowHeight="15"/>'+
29398 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29399 return{sc:sc,nc:nc,row:row,xml:xml};
29400 }
29401 // Language breakdown
29402 var lm={};
29403 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;});
29404 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29405 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29406 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29407 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29408 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29409 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29410 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):'';}
29411 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29412 // Summary sheet
29413 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29414 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29415 r1(s1(0,proj,2));
29416 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29417 r1('');
29418 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29419 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))));
29420 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))));
29421 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))));
29422 r1('');
29423 r1(s1(0,'FILE CHANGES',8));
29424 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29425 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29426 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29427 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29428 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29429 if(langs.length){
29430 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29431 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29432 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)));});
29433 }
29434 r1('');r1(s1(0,'SCAN METADATA',8));
29435 r1(s1(1,_blabel)+s1(2,_clabel));
29436 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29437 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29438 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"/>');
29439 // File Delta sheet
29440 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29441 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));
29442 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)));});
29443 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29444 // Shared strings XML
29445 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29446 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29447 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29448 // XLSX file map
29449 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29450 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>',
29451 '_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>',
29452 '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>',
29453 '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>',
29454 '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>',
29455 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29456 // ZIP packer — STORED (no compression), compatible with all XLSX readers
29457 var zparts=[],zcds=[],zoff=0,znf=0;
29458 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29459 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29460 ].forEach(function(name){
29461 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29462 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]);
29463 var entry=new Uint8Array(lha.length+nb.length+sz);
29464 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29465 zparts.push(entry);
29466 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));
29467 var cde=new Uint8Array(cda.length+nb.length);
29468 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29469 zcds.push(cde);zoff+=entry.length;znf++;
29470 });
29471 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29472 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]);
29473 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29474 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29475 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29476 zout.set(new Uint8Array(ea),zpos);
29477 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29478 var xurl=URL.createObjectURL(xblob);
29479 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29480 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29481 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29482 }
29483 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;');}
29484 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29485 function getExportFilename(ext){return _exportBase+'.'+ext;}
29486
29487 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 }}'};
29488 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;}
29489 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29490 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29491 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29492 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29493 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):'';}
29494 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29495 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)]];}
29496 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29497 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)];});}
29498 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29499 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29500
29501 // ── Chart HTML report ─────────────────────────────────────────────────────
29502 function slocChartReport(fname, sd, dr) {
29503 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29504 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29505 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29506 function fmt(n){return Number(n).toLocaleString();}
29507 function px(n){return Math.round(n);}
29508 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29509 // Language map
29510 var lm={};
29511 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;});
29512 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29513
29514 // Builds onmouse* attrs for interactive tooltip on each SVG element
29515 function barTT(label,val){
29516 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29517 }
29518
29519 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29520 // match the Language Code Delta column height) ────────────
29521 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'}];
29522 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29523 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29524 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29525 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29526 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"/>';}
29527 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29528 c1mets.forEach(function(m,i){
29529 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29530 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29531 var gMax=Math.max(m.b,m.c)*1.15||1;
29532 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29533 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>';
29534 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))+'/>';
29535 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>';
29536 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))+'/>';
29537 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>';
29538 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>';
29539 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>';
29540 });
29541 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>';
29542 c1+='</svg>';
29543
29544 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29545 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'}];
29546 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29547 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29548 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29549 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29550 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29551 mets.forEach(function(m,i){
29552 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29553 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29554 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29555 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>';
29556 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29557 if(bw>=52){
29558 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>';
29559 }else{
29560 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29561 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>';
29562 }
29563 });
29564 c2+='</svg>';
29565
29566 // ── Chart 3: Language Code Delta ─────────────────────────────────────
29567 var c3='';
29568 if(langs.length){
29569 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29570 var C3W=550,c3LW=124,c3FW=52;
29571 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29572 var L3rH=30,C3H=langs.length*L3rH+20;
29573 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29574 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29575 langs.forEach(function(l,i){
29576 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29577 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29578 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29579 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>';
29580 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':''))+'/>';
29581 if(bw>=48){
29582 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>';
29583 }else{
29584 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29585 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>';
29586 }
29587 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>';
29588 });
29589 c3+='</svg>';
29590 }
29591
29592 // ── Chart 4: File Change Donut — centered pie with legend below
29593 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;});
29594 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29595 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29596 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">';
29597 var ang=-Math.PI/2;
29598 segs.forEach(function(s){
29599 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29600 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29601 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29602 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29603 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29604 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)+'%')+'/>';
29605 ang+=sw;
29606 });
29607 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>';
29608 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>';
29609 segs.forEach(function(s,i){
29610 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29611 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29612 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>';
29613 });
29614 c4+='</svg>';
29615
29616 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29617 var ttJs='var tt=document.getElementById("ox-tt");'+
29618 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29619 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29620 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29621 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29622 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29623 'function oxHT(){tt.style.display="none";}';
29624
29625 // body max-width keeps charts from inflating beyond design dimensions on
29626 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29627 // each chart's height blows up proportionally, breaking the one-page layout.
29628 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;}'+
29629 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29630 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29631 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29632 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29633 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29634 'svg{display:block;}'+
29635 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29636 '#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;}'+
29637 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29638 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29639 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29640 '<div id="ox-tt"><\/div>'+
29641 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
29642 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
29643 '<div class="two-col">'+
29644 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
29645 '<div class="leg">'+
29646 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29647 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29648 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29649 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
29650 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29651 '<\/div>'+
29652 '<div class="two-col">'+
29653 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29654 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29655 '<\/div>'+
29656 '<script>'+ttJs+'<\/script>'+
29657 '<\/body><\/html>';
29658 slocDownload(html, fname, 'text/html;charset=utf-8;');
29659 }
29660 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29661 window.buildDeltaChartsHtml = function() {
29662 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29663 var sd=_sd;
29664 var projEl=document.querySelector('[data-folder]');
29665 var proj=projEl?projEl.getAttribute('data-folder'):'';
29666 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29667 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29668 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29669 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29670 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";}';
29671 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);}';
29672 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29673 '<div id="ox-tt"><\/div>'+
29674 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29675 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29676 '<div class="two-col">'+
29677 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29678 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29679 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29680 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29681 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29682 '<\/div>'+
29683 '<div class="two-col">'+
29684 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29685 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29686 '<\/div>'+
29687 '<script>'+ttJs+'<\/script>'+
29688 '<\/body><\/html>';
29689 };
29690 // ── Inline delta charts ────────────────────────────────────────────────────
29691 var _icTT=document.getElementById('ic-tt');
29692 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29693 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';};
29694 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29695 window.addEventListener('blur',function(){window.icHT();});
29696 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29697 (function(){
29698 // Theme-aware palette — matches the canonical scheme used by /test-metrics
29699 // charts so every page renders bars/text/grid with the same colours and
29700 // adapts to dark mode (see Design section in CLAUDE.md).
29701 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29702 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29703 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29704 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29705 // washed) so the chart reads with the same weight as /test-metrics.
29706 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29707 var FADE=dark?'#524238':'#e6d0bf';
29708 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29709 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29710 function fmt(n){return Number(n).toLocaleString();}
29711 function px(n){return Math.round(n);}
29712 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29713 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29714 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);});}
29715 var dr=getDeltaExportRows(),sd=_sd,lm={};
29716 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;});
29717 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29718 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29719 // the bars are as tall as the (usually taller) Language Code Delta sibling that
29720 // shares the same grid row, instead of sitting short at the top.
29721 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}];
29722 function drawC1(){
29723 var C1W=600,C1H=188;
29724 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29725 if(host&&card&&host.clientWidth>0){
29726 var avW=host.clientWidth;
29727 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29728 var wantH=availPx*C1W/avW;
29729 if(wantH>C1H)C1H=wantH;
29730 }
29731 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29732 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29733 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"/>';}
29734 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29735 c1mets.forEach(function(m,i){
29736 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29737 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29738 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29739 var gMax=Math.max(m.b,m.c)*1.15||1;
29740 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29741 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>';
29742 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"/>';
29743 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>';
29744 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"/>';
29745 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>';
29746 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>';
29747 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>';
29748 });
29749 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>';
29750 c1+='</svg>';
29751 return c1;
29752 }
29753 var c1=drawC1();
29754 // Chart 2: Delta by Metric
29755 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}];
29756 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29757 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;
29758 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29759 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29760 mets.forEach(function(m,i){
29761 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);
29762 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>';
29763 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29764 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>';}
29765 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>';}
29766 });
29767 c2+='</svg>';
29768 // Chart 3: Language Code Delta
29769 var c3='';
29770 if(langs.length){
29771 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29772 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;
29773 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29774 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29775 langs.forEach(function(l,i){
29776 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);
29777 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>';
29778 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"/>';
29779 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>';}
29780 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>';}
29781 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>';
29782 });
29783 c3+='</svg>';
29784 }
29785 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29786 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29787 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;});
29788 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29789 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29790 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);
29791 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;
29792 if(segs.length===1){
29793 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29794 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+'"/>';
29795 } else {
29796 // Give every visible slice a small minimum sweep, taken from the largest
29797 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29798 // arc whose start and end points coincide, so SVG renders nothing (blank).
29799 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29800 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29801 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29802 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29803 segs.forEach(function(s,si){
29804 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29805 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);
29806 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);
29807 var pct=Math.round(s.v/tot*100);
29808 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"/>';
29809 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>';}
29810 ang+=sw;
29811 });
29812 }
29813 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29814 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29815 segs.forEach(function(s,i){
29816 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29817 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29818 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29819 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29820 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29821 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>';
29822 c4+='</g>';
29823 });
29824 c4+='</svg>';
29825 // Inject the fixed-height siblings first so the grid row settles to the (taller)
29826 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29827 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29828 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);}
29829 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29830 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29831 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29832
29833 // Compare Timeline chart (Baseline vs Current, 2 points)
29834 (function() {
29835 var activeCmpMetric='code';
29836 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29837 function renderCmpTL(metric, targetSvg, targetH) {
29838 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29839 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29840 svg.setAttribute('height',H);
29841 var pad={l:62,r:20,t:32,b:72};
29842 var dark=document.body.classList.contains('dark-theme');
29843 var cmpPts=[
29844 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29845 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29846 ];
29847 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29848 var valid=pts.filter(function(v){return v!=null;});
29849 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;}
29850 var minV=0,maxV=Math.max.apply(null,valid);
29851 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29852 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29853 var cx0=pad.l,cx1=pad.l+plotW;
29854 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29855 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29856 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29857 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29858 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29859 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();}
29860 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
29861 var parts=[];
29862 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29863 for(var gi=0;gi<5;gi++){
29864 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29865 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29866 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29867 }
29868 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+'"/>');
29869 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"/>');
29870 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29871 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29872 dotPts.forEach(function(pt){
29873 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>');
29874 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"/>');
29875 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>');
29876 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>');
29877 });
29878 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29879 svg.setAttribute('viewBox','0 0 '+W+' '+H);
29880 svg.innerHTML=parts.join('');
29881 // Hover: crosshair + tooltip (matches multi-scan timeline)
29882 var cmpTT=document.getElementById('ic-tt');
29883 svg.onmousemove=function(e){
29884 var rect=svg.getBoundingClientRect();
29885 var scaleX=W/rect.width;
29886 var mouseX=(e.clientX-rect.left)*scaleX;
29887 var nearest=-1,minDist=Infinity;
29888 var cxArr=[cx0,cx1];
29889 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;}}
29890 if(nearest<0)return;
29891 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
29892 var xhair=svg.querySelector('.cmp-xhair');
29893 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
29894 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"/>';
29895 if(!cmpTT)return;
29896 var clbl=cmpPts[nearest].label;
29897 var scanLbl=nearest===0?'Baseline':'Current';
29898 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>';
29899 var bx=rect.left+(nc/W*rect.width)+18;
29900 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
29901 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
29902 };
29903 svg.onmouseleave=function(){
29904 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
29905 if(cmpTT)cmpTT.style.display='none';
29906 };
29907 }
29908 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
29909 btn.addEventListener('click',function(){
29910 activeCmpMetric=this.dataset.cmpMetric;
29911 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
29912 this.classList.add('active');
29913 renderCmpTL(activeCmpMetric);
29914 });
29915 });
29916 var ttgl=document.getElementById('theme-toggle');
29917 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
29918 if(typeof ResizeObserver!=='undefined'){
29919 var cmpSvg=document.getElementById('cmp-tl-svg');
29920 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
29921 }
29922 // Expose the timeline renderer + current metric so the Full View modal can
29923 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
29924 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
29925 window.__sdGetMetric=function(){return activeCmpMetric;};
29926 renderCmpTL(activeCmpMetric);
29927 })();
29928
29929 // HTML legend hover -> highlight matching SVG bars within the SAME card only
29930 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
29931 var metric=leg.getAttribute('data-highlight');
29932 var parentCard=leg.closest('.ic-card');
29933 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
29934 if(!chartEl)return;
29935 leg.addEventListener('mouseenter',function(){
29936 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
29937 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';}
29938 else{x.style.opacity='0.28';}
29939 });
29940 });
29941 leg.addEventListener('mouseleave',function(){
29942 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
29943 });
29944 });
29945
29946 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
29947 (function(){
29948 var ov=document.getElementById('ic-svg-modal-ov');
29949 var body=document.getElementById('ic-svg-modal-body');
29950 var ttl=document.getElementById('ic-svg-modal-title');
29951 var closeBtn=document.getElementById('ic-svg-modal-close');
29952 if(!ov||!body)return;
29953 function close(){
29954 ov.classList.remove('open');body.innerHTML='';
29955 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
29956 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
29957 }
29958 function open(srcId,title){
29959 var src=document.getElementById(srcId);if(!src)return;
29960 if(ttl)ttl.textContent=title||'';
29961 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
29962 // snapshot stretches and loses interactivity. Re-render it live into the modal at
29963 // full size instead — keeps proportions, animation, crosshair, tooltip and the
29964 // metric tabs working exactly like the inline chart.
29965 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
29966 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
29967 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
29968 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('');
29969 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>';
29970 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
29971 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
29972 ov.classList.add('open');
29973 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
29974 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;}
29975 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
29976 b.addEventListener('click',function(){
29977 if(!window.__sdFvTL)return;
29978 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
29979 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
29980 this.classList.add('active');
29981 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
29982 });
29983 });
29984 return;
29985 }
29986 var card=src.closest('.ic-card');
29987 var legHtml='';
29988 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
29989 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
29990 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;}
29991 body.innerHTML=legHtml+inner;
29992 var svg=body.querySelector('svg');
29993 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
29994 addTT(body);
29995 ov.classList.add('open');
29996 }
29997 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
29998 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
29999 });
30000 if(closeBtn)closeBtn.addEventListener('click',close);
30001 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30002 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30003 })();
30004
30005 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30006 })();
30007 </script>
30008 {{ toast_assets|safe }}
30009 <script nonce="{{ csp_nonce }}">
30010 (function(){
30011 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'}];
30012 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);});}
30013 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30014 function init(){
30015 var btn=document.getElementById('settings-btn');if(!btn)return;
30016 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30017 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>';
30018 document.body.appendChild(m);
30019 var g=document.getElementById('scheme-grid');
30020 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);});
30021 var cl=document.getElementById('settings-close');
30022 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);
30023 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');});
30024 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30025 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30026 }
30027 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30028 }());
30029 </script>
30030 <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]';
30031 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;}
30032 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>
30033</body>
30034</html>
30035"##,
30036 ext = "html"
30037)]
30038#[allow(clippy::struct_excessive_bools)]
30040struct CompareTemplate {
30041 loading_overlay: String,
30043 version: &'static str,
30044 project_label: String,
30045 baseline_git_commit: String,
30046 current_git_commit: String,
30047 baseline_run_id: String,
30048 current_run_id: String,
30049 baseline_run_id_short: String,
30050 current_run_id_short: String,
30051 baseline_timestamp: String,
30052 baseline_timestamp_utc_ms: i64,
30053 current_timestamp: String,
30054 current_timestamp_utc_ms: i64,
30055 project_path: String,
30056 baseline_code: u64,
30057 current_code: u64,
30058 code_lines_delta_str: String,
30059 code_lines_delta_class: String,
30060 baseline_files: u64,
30061 current_files: u64,
30062 files_analyzed_delta_str: String,
30063 files_analyzed_delta_class: String,
30064 baseline_comments: u64,
30065 current_comments: u64,
30066 comment_lines_delta_str: String,
30067 comment_lines_delta_class: String,
30068 baseline_code_fmt: String,
30069 current_code_fmt: String,
30070 baseline_files_fmt: String,
30071 current_files_fmt: String,
30072 baseline_comments_fmt: String,
30073 current_comments_fmt: String,
30074 code_lines_pct_str: String,
30075 files_analyzed_pct_str: String,
30076 comment_lines_pct_str: String,
30077 code_lines_added: i64,
30078 code_lines_removed: i64,
30079 new_scope: bool,
30081 churn_rate_str: String,
30082 churn_rate_class: String,
30083 scope_flag: bool,
30084 files_added: usize,
30085 files_removed: usize,
30086 files_modified: usize,
30087 files_unchanged: usize,
30088 file_rows: Vec<CompareFileDeltaRow>,
30089 baseline_git_author: Option<String>,
30090 current_git_author: Option<String>,
30091 baseline_git_branch: String,
30092 current_git_branch: String,
30093 baseline_git_tags: Option<String>,
30094 current_git_tags: Option<String>,
30095 baseline_git_commit_date: Option<String>,
30096 current_git_commit_date: Option<String>,
30097 project_name: String,
30098 submodule_options: Vec<String>,
30100 has_any_submodule_data: bool,
30102 active_submodule: Option<String>,
30104 super_scope_active: bool,
30106 csp_nonce: String,
30107 toast_assets: String,
30109 coverage_delta_card: String,
30111 baseline_test_count: u64,
30112 current_test_count: u64,
30113 baseline_coverage_pct: Option<f64>,
30114 current_coverage_pct: Option<f64>,
30115}
30116
30117#[derive(Template)]
30120#[template(
30121 source = r##"
30122<!doctype html>
30123<html lang="en">
30124<head>
30125 <meta charset="utf-8">
30126 <meta name="viewport" content="width=device-width, initial-scale=1">
30127 <title>OxideSLOC | Sign In</title>
30128 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30129 <style nonce="{{ csp_nonce }}">
30130 :root {
30131 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30132 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30133 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30134 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30135 }
30136 *{box-sizing:border-box;}
30137 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);}
30138 .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);}
30139 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30140 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30141 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30142 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30143 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30144 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30145 .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;}
30146 @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));}}
30147 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30148 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30149 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30150 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30151 .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;}
30152 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30153 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;}
30154 input[type=password]:focus{border-color:var(--oxide);}
30155 .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;}
30156 .btn:hover{opacity:.88;}
30157 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30158 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30159 </style>
30160</head>
30161<body>
30162 <div class="background-watermarks" aria-hidden="true">
30163 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30164 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30165 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30166 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30167 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30168 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30169 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30170 </div>
30171 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30172<nav class="top-nav">
30173 <a class="brand" href="/">
30174 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30175 <span class="brand-title">OxideSLOC</span>
30176 </a>
30177</nav>
30178<main class="page">
30179 <div class="card">
30180 <h1>Sign In</h1>
30181 <p class="subtitle">Enter the API key printed when the server started.</p>
30182 {% if has_error %}
30183 <div class="error">Incorrect API key — please try again.</div>
30184 {% endif %}
30185 <form method="POST" action="/auth/login">
30186 <input type="hidden" name="next" value="{{ next_url|e }}">
30187 <label for="key">API Key</label>
30188 <input id="key" type="password" name="key" autocomplete="current-password"
30189 placeholder="Paste your API key here" autofocus>
30190 <button type="submit" class="btn">Sign In</button>
30191 </form>
30192 <p class="hint">
30193 The API key was printed in the terminal when the server started.<br>
30194 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30195 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30196 </p>
30197 </div>
30198</main>
30199<script nonce="{{ csp_nonce }}">
30200(function() {
30201 (function randomizeWatermarks() {
30202 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30203 if (!wms.length) return;
30204 var placed = [];
30205 function tooClose(top, left) {
30206 for (var i = 0; i < placed.length; i++) {
30207 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30208 if (dt < 16 && dl < 12) return true;
30209 }
30210 return false;
30211 }
30212 function pick(leftBand) {
30213 for (var attempt = 0; attempt < 50; attempt++) {
30214 var top = Math.random() * 88 + 2;
30215 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30216 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30217 }
30218 var top = Math.random() * 88 + 2;
30219 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30220 placed.push([top, left]); return [top, left];
30221 }
30222 var half = Math.floor(wms.length / 2);
30223 wms.forEach(function (img, i) {
30224 var pos = pick(i < half);
30225 var size = Math.floor(Math.random() * 100 + 120);
30226 var rot = (Math.random() * 360).toFixed(1);
30227 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30228 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;
30229 });
30230 })();
30231 (function spawnCodeParticles() {
30232 var container = document.getElementById('code-particles');
30233 if (!container) return;
30234 var snippets = [
30235 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30236 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30237 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30238 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30239 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30240 ];
30241 var count = 38;
30242 for (var i = 0; i < count; i++) {
30243 (function(idx) {
30244 var el = document.createElement('span');
30245 el.className = 'code-particle';
30246 el.textContent = snippets[idx % snippets.length];
30247 var left = Math.random() * 94 + 2;
30248 var top = Math.random() * 88 + 6;
30249 var dur = (Math.random() * 10 + 9).toFixed(1);
30250 var delay = (Math.random() * 18).toFixed(1);
30251 var rot = (Math.random() * 26 - 13).toFixed(1);
30252 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30253 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30254 container.appendChild(el);
30255 })(i);
30256 }
30257 })();
30258})();
30259</script>
30260</body>
30261</html>
30262"##,
30263 ext = "html"
30264)]
30265pub(crate) struct LoginTemplate {
30266 pub(crate) csp_nonce: String,
30267 pub(crate) has_error: bool,
30268 pub(crate) next_url: String,
30269 pub(crate) lockout_threshold: u32,
30270}
30271
30272#[derive(Template)]
30275#[template(
30276 source = r##"
30277<!doctype html>
30278<html lang="en">
30279<head>
30280 <meta charset="utf-8">
30281 <meta name="viewport" content="width=device-width, initial-scale=1">
30282 <title>OxideSLOC — REST API Reference</title>
30283 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30284 <style nonce="{{ csp_nonce }}">
30285 :root {
30286 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30287 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30288 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30289 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30290 --success:#16a34a;
30291 }
30292 body.dark-theme {
30293 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30294 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30295 }
30296 *{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;}
30297 .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);}
30298 .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;}
30299 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30300 .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));}
30301 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30302 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30303 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30304 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30305 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30306 @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; } }
30307 .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;}
30308 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30309 .nav-pill.active{background:rgba(255,255,255,0.22);}
30310 .nav-dropdown{position:relative;display:inline-flex;}
30311 .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;}
30312 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30313 .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;}
30314 .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;}
30315 .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);}
30316 .nav-dropdown-menu a:last-child{border-bottom:none;}
30317 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30318 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30319 .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;}
30320 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30321 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30322 .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;}
30323 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30324 .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);}
30325 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30326 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30327 .settings-modal-body{padding:14px 16px 16px;}
30328 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30329 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30330 .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;}
30331 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30332 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30333 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30334 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30335 .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;}
30336 .tz-select:focus{border-color:var(--oxide);}
30337 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30338 .page-header{margin-bottom:28px;}
30339 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30340 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30341 .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;}
30342 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30343 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30344 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30345 .callout strong{font-weight:800;}
30346 .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;}
30347 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30348 .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;}
30349 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30350 .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;}
30351 body.dark-theme .base-url-value{color:var(--accent);}
30352 .section{margin-bottom:36px;}
30353 .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);}
30354 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30355 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30356 .ep-header:hover{background:var(--surface-2);}
30357 .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;}
30358 .method.get{background:#dcfce7;color:#166534;}
30359 .method.post{background:#dbeafe;color:#1e40af;}
30360 .method.delete{background:#fee2e2;color:#991b1b;}
30361 body.dark-theme .method.get{background:#14532d;color:#86efac;}
30362 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30363 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30364 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30365 .ep-path .param{color:var(--oxide-2);}
30366 body.dark-theme .ep-path .param{color:var(--oxide);}
30367 .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;}
30368 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30369 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30370 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30371 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30372 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30373 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30374 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30375 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30376 .ep-card.open .chevron{transform:rotate(180deg);}
30377 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30378 .ep-card.open .ep-body{display:block;}
30379 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30380 .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;}
30381 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30382 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30383 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30384 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30385 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);}
30386 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30387 table.params tr:last-child td{border-bottom:none;}
30388 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30389 .pt-type{color:var(--muted-2);font-size:12px;}
30390 .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;}
30391 .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;}
30392 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30393 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30394 details.schema{margin-bottom:14px;}
30395 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;}
30396 details.schema summary:hover{color:var(--text);}
30397 .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;}
30398 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30399 .curl-wrap{position:relative;}
30400 .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;}
30401 .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;}
30402 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30403 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30404 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30405 .webhook-note a{color:var(--accent-2);text-decoration:none;}
30406 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30407 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30408 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30409 .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;}
30410 @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));}}
30411 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30412 .site-footer a{color:var(--muted);}
30413 </style>
30414</head>
30415<body>
30416 <div class="background-watermarks" aria-hidden="true">
30417 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30418 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30419 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30420 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30421 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30422 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30423 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30424 </div>
30425 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30426 <div class="top-nav">
30427 <div class="top-nav-inner">
30428 <a class="brand" href="/">
30429 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30430 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30431 </a>
30432 <div class="nav-right">
30433 <a class="nav-pill" href="/">Home</a>
30434 <div class="nav-dropdown">
30435 <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>
30436 <div class="nav-dropdown-menu">
30437 <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>
30438 </div>
30439 </div>
30440 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30441 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30442 <div class="nav-dropdown">
30443 <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>
30444 <div class="nav-dropdown-menu">
30445 <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>
30446 </div>
30447 </div>
30448 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30449 <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>
30450 </button>
30451 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30452 <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>
30453 <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>
30454 </button>
30455 </div>
30456 </div>
30457 </div>
30458
30459 <div class="page">
30460 <div class="page-header">
30461 <h1 class="page-title">REST API Reference</h1>
30462 <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>
30463 </div>
30464
30465 {% if has_api_key %}
30466 <div class="callout key-set">
30467 <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>
30468 <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>
30469 </div>
30470 {% else %}
30471 <div class="callout no-key">
30472 <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>
30473 <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>
30474 </div>
30475 {% endif %}
30476
30477 <div class="base-url-bar">
30478 <span class="base-url-label">Base URL</span>
30479 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30480 </div>
30481
30482 <!-- Health -->
30483 <div class="section">
30484 <h2 class="section-title">Health & Status</h2>
30485 <div class="ep-card">
30486 <div class="ep-header">
30487 <span class="method get">GET</span>
30488 <span class="ep-path">/healthz</span>
30489 <span class="auth-badge public">Public</span>
30490 <span class="ep-desc">Server liveness check</span>
30491 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30492 </div>
30493 <div class="ep-body">
30494 <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>
30495 <p class="params-heading">Response</p>
30496 <div class="schema-block">200 OK
30497Content-Type: text/plain
30498
30499ok</div>
30500 <p class="curl-heading">Example</p>
30501 <div class="curl-wrap">
30502 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30503 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30504 </div>
30505 </div>
30506 </div>
30507 </div>
30508
30509 <!-- Badges -->
30510 <div class="section">
30511 <h2 class="section-title">Badges</h2>
30512 <div class="ep-card">
30513 <div class="ep-header">
30514 <span class="method get">GET</span>
30515 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30516 <span class="auth-badge public">Public</span>
30517 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30518 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30519 </div>
30520 <div class="ep-body">
30521 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30522 <p class="params-heading">Path Parameters</p>
30523 <table class="params">
30524 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30525 <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>
30526 </table>
30527 <p class="curl-heading">Example</p>
30528 <div class="curl-wrap">
30529 <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>
30530 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30531 </div>
30532 </div>
30533 </div>
30534 </div>
30535
30536 <!-- Metrics -->
30537 <div class="section">
30538 <h2 class="section-title">Metrics</h2>
30539
30540 <div class="ep-card">
30541 <div class="ep-header">
30542 <span class="method get">GET</span>
30543 <span class="ep-path">/api/metrics/latest</span>
30544 <span class="auth-badge protected">Protected</span>
30545 <span class="ep-desc">Latest scan metrics (JSON)</span>
30546 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30547 </div>
30548 <div class="ep-body">
30549 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30550 <details class="schema"><summary>Response schema</summary>
30551<div class="schema-block">{
30552 "run_id": string, // UUID
30553 "timestamp": string, // ISO-8601 UTC
30554 "project": string, // scanned root path
30555 "summary": {
30556 "files_analyzed": number,
30557 "files_skipped": number,
30558 "code_lines": number,
30559 "comment_lines": number,
30560 "blank_lines": number,
30561 "total_physical_lines": number,
30562 "functions": number,
30563 "classes": number,
30564 "variables": number,
30565 "imports": number
30566 },
30567 "languages": [
30568 { "name": string, "files": number, "code_lines": number,
30569 "comment_lines": number, "blank_lines": number,
30570 "functions": number, "classes": number,
30571 "variables": number, "imports": number }
30572 ]
30573}</div></details>
30574 <p class="curl-heading">Example</p>
30575 <div class="curl-wrap">
30576 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30577 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30578 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30579 </div>
30580 </div>
30581 </div>
30582
30583 <div class="ep-card">
30584 <div class="ep-header">
30585 <span class="method get">GET</span>
30586 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30587 <span class="auth-badge protected">Protected</span>
30588 <span class="ep-desc">Metrics for a specific run</span>
30589 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30590 </div>
30591 <div class="ep-body">
30592 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30593 <p class="params-heading">Path Parameters</p>
30594 <table class="params">
30595 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30596 <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>
30597 </table>
30598 <p class="curl-heading">Example</p>
30599 <div class="curl-wrap">
30600 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30601 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
30602 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30603 </div>
30604 </div>
30605 </div>
30606
30607 <div class="ep-card">
30608 <div class="ep-header">
30609 <span class="method get">GET</span>
30610 <span class="ep-path">/api/metrics/history</span>
30611 <span class="auth-badge protected">Protected</span>
30612 <span class="ep-desc">Paginated scan history</span>
30613 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30614 </div>
30615 <div class="ep-body">
30616 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30617 <p class="params-heading">Query Parameters</p>
30618 <table class="params">
30619 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30620 <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>
30621 <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>
30622 </table>
30623 <details class="schema"><summary>Response schema</summary>
30624<div class="schema-block">[{
30625 "run_id": string,
30626 "timestamp": string, // ISO-8601 UTC
30627 "commit": string | null,
30628 "branch": string | null,
30629 "tags": string[],
30630 "code_lines": number,
30631 "comment_lines": number,
30632 "blank_lines": number,
30633 "physical_lines": number,
30634 "files_analyzed": number,
30635 "project_label": string,
30636 "html_url": string | null
30637}]</div></details>
30638 <p class="curl-heading">Example</p>
30639 <div class="curl-wrap">
30640 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30641 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30642 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30643 </div>
30644 </div>
30645 </div>
30646
30647 <div class="ep-card">
30648 <div class="ep-header">
30649 <span class="method get">GET</span>
30650 <span class="ep-path">/api/project-history</span>
30651 <span class="auth-badge protected">Protected</span>
30652 <span class="ep-desc">Project-level scan summary</span>
30653 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30654 </div>
30655 <div class="ep-body">
30656 <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>
30657 <p class="params-heading">Query Parameters</p>
30658 <table class="params">
30659 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30660 <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>
30661 </table>
30662 <details class="schema"><summary>Response schema</summary>
30663<div class="schema-block">{
30664 "scan_count": number,
30665 "last_scan_id": string | null,
30666 "last_scan_timestamp": string | null, // ISO-8601
30667 "last_scan_code_lines": number | null,
30668 "last_git_branch": string | null,
30669 "last_git_commit": string | null
30670}</div></details>
30671 <p class="curl-heading">Example</p>
30672 <div class="curl-wrap">
30673 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30674 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30675 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30676 </div>
30677 </div>
30678 </div>
30679
30680 <div class="ep-card">
30681 <div class="ep-header">
30682 <span class="method get">GET</span>
30683 <span class="ep-path">/api/metrics/submodules</span>
30684 <span class="auth-badge protected">Protected</span>
30685 <span class="ep-desc">List known git submodules across scans</span>
30686 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30687 </div>
30688 <div class="ep-body">
30689 <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>
30690 <p class="params-heading">Query Parameters</p>
30691 <table class="params">
30692 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30693 <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>
30694 </table>
30695 <details class="schema"><summary>Response schema</summary>
30696<div class="schema-block">[{
30697 "name": string, // submodule name
30698 "relative_path": string // path relative to the project root
30699}]</div></details>
30700 <p class="curl-heading">Example</p>
30701 <div class="curl-wrap">
30702 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30703 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30704 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30705 </div>
30706 </div>
30707 </div>
30708 </div>
30709
30710 <!-- Async Run Status -->
30711 <div class="section">
30712 <h2 class="section-title">Async Run Status</h2>
30713
30714 <div class="ep-card">
30715 <div class="ep-header">
30716 <span class="method get">GET</span>
30717 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30718 <span class="auth-badge protected">Protected</span>
30719 <span class="ep-desc">Poll scan completion</span>
30720 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30721 </div>
30722 <div class="ep-body">
30723 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30724 <details class="schema"><summary>Response schema</summary>
30725<div class="schema-block">// Running
30726{ "state": "running", "elapsed_secs": number }
30727
30728// Complete
30729{ "state": "complete", "run_id": string }
30730
30731// Failed
30732{ "state": "failed", "message": string }</div></details>
30733 <p class="curl-heading">Example</p>
30734 <div class="curl-wrap">
30735 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30736 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
30737 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30738 </div>
30739 </div>
30740 </div>
30741
30742 <div class="ep-card">
30743 <div class="ep-header">
30744 <span class="method get">GET</span>
30745 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30746 <span class="auth-badge protected">Protected</span>
30747 <span class="ep-desc">Poll PDF generation readiness</span>
30748 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30749 </div>
30750 <div class="ep-body">
30751 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30752 <details class="schema"><summary>Response schema</summary>
30753<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30754 <p class="curl-heading">Example</p>
30755 <div class="curl-wrap">
30756 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30757 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
30758 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30759 </div>
30760 </div>
30761 </div>
30762
30763 <div class="ep-card">
30764 <div class="ep-header">
30765 <span class="method post">POST</span>
30766 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30767 <span class="auth-badge protected">Protected</span>
30768 <span class="ep-desc">Cancel a running scan</span>
30769 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30770 </div>
30771 <div class="ep-body">
30772 <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>
30773 <p class="curl-heading">Example</p>
30774 <div class="curl-wrap">
30775 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30776 -H "Authorization: Bearer $SLOC_API_KEY" \
30777 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
30778 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30779 </div>
30780 </div>
30781 </div>
30782 </div>
30783
30784 <!-- Run Management -->
30785 <div class="section">
30786 <h2 class="section-title">Run Management</h2>
30787
30788 <div class="ep-card">
30789 <div class="ep-header">
30790 <span class="method get">GET</span>
30791 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30792 <span class="auth-badge protected">Protected</span>
30793 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30794 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30795 </div>
30796 <div class="ep-body">
30797 <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>
30798 <p class="params-heading">Path Parameters</p>
30799 <table class="params">
30800 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30801 <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>
30802 </table>
30803 <details class="schema"><summary>Response</summary>
30804<div class="schema-block">200 OK — Content-Type: application/zip
30805Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
30806
30807404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
30808 <p class="curl-heading">Example</p>
30809 <div class="curl-wrap">
30810 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30811 -o run.zip \
30812 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
30813 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30814 </div>
30815 </div>
30816 </div>
30817
30818 <div class="ep-card">
30819 <div class="ep-header">
30820 <span class="method delete">DELETE</span>
30821 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30822 <span class="auth-badge protected">Protected</span>
30823 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30824 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30825 </div>
30826 <div class="ep-body">
30827 <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>
30828 <p class="params-heading">Path Parameters</p>
30829 <table class="params">
30830 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30831 <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>
30832 </table>
30833 <details class="schema"><summary>Response</summary>
30834<div class="schema-block">204 No Content — run successfully deleted
30835
30836500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
30837 <p class="curl-heading">Example</p>
30838 <div class="curl-wrap">
30839 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30840 -H "Authorization: Bearer $SLOC_API_KEY" \
30841 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
30842 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30843 </div>
30844 </div>
30845 </div>
30846
30847 <div class="ep-card">
30848 <div class="ep-header">
30849 <span class="method post">POST</span>
30850 <span class="ep-path">/api/runs/cleanup</span>
30851 <span class="auth-badge protected">Protected</span>
30852 <span class="ep-desc">Bulk delete runs older than N days</span>
30853 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30854 </div>
30855 <div class="ep-body">
30856 <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>
30857 <p class="params-heading">Request Body (application/json)</p>
30858 <table class="params">
30859 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30860 <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>
30861 </table>
30862 <details class="schema"><summary>Response schema</summary>
30863<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
30864 <p class="curl-heading">Example — delete runs older than 60 days</p>
30865 <div class="curl-wrap">
30866 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30867 -H "Authorization: Bearer $SLOC_API_KEY" \
30868 -H "Content-Type: application/json" \
30869 -d '{"older_than_days":60}' \
30870 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30871 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30872 </div>
30873 </div>
30874 </div>
30875 </div>
30876
30877 <!-- Retention Policy -->
30878 <div class="section">
30879 <h2 class="section-title">Retention Policy</h2>
30880
30881 <div class="ep-card">
30882 <div class="ep-header">
30883 <span class="method get">GET</span>
30884 <span class="ep-path">/api/cleanup-policy</span>
30885 <span class="auth-badge protected">Protected</span>
30886 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30887 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30888 </div>
30889 <div class="ep-body">
30890 <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>
30891 <details class="schema"><summary>Response schema</summary>
30892<div class="schema-block">{
30893 "policy": {
30894 "enabled": boolean,
30895 "max_age_days": number | null, // delete runs older than N days
30896 "max_run_count": number | null, // keep only the N most recent runs
30897 "interval_hours": number // hours between background passes
30898 } | null,
30899 "last_run_at": string | null, // ISO-8601 UTC timestamp
30900 "last_run_deleted": number | null // runs deleted in last pass
30901}</div></details>
30902 <p class="curl-heading">Example</p>
30903 <div class="curl-wrap">
30904 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30905 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30906 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
30907 </div>
30908 </div>
30909 </div>
30910
30911 <div class="ep-card">
30912 <div class="ep-header">
30913 <span class="method post">POST</span>
30914 <span class="ep-path">/api/cleanup-policy</span>
30915 <span class="auth-badge protected">Protected</span>
30916 <span class="ep-desc">Save or update the retention policy</span>
30917 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30918 </div>
30919 <div class="ep-body">
30920 <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>
30921 <p class="params-heading">Request Body (application/json)</p>
30922 <table class="params">
30923 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30924 <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>
30925 <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>
30926 <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>
30927 <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>
30928 </table>
30929 <details class="schema"><summary>Response</summary>
30930<div class="schema-block">204 No Content — policy saved and task (re)started
30931
30932500 Internal Server Error — { "error": string }</div></details>
30933 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
30934 <div class="curl-wrap">
30935 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
30936 -H "Authorization: Bearer $SLOC_API_KEY" \
30937 -H "Content-Type: application/json" \
30938 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
30939 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30940 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
30941 </div>
30942 </div>
30943 </div>
30944
30945 <div class="ep-card">
30946 <div class="ep-header">
30947 <span class="method post">POST</span>
30948 <span class="ep-path">/api/cleanup-policy/run-now</span>
30949 <span class="auth-badge protected">Protected</span>
30950 <span class="ep-desc">Trigger an immediate cleanup pass</span>
30951 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30952 </div>
30953 <div class="ep-body">
30954 <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>
30955 <details class="schema"><summary>Response schema</summary>
30956<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
30957 <p class="curl-heading">Example</p>
30958 <div class="curl-wrap">
30959 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
30960 -H "Authorization: Bearer $SLOC_API_KEY" \
30961 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
30962 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
30963 </div>
30964 </div>
30965 </div>
30966
30967 <div class="ep-card">
30968 <div class="ep-header">
30969 <span class="method delete">DELETE</span>
30970 <span class="ep-path">/api/cleanup-policy</span>
30971 <span class="auth-badge protected">Protected</span>
30972 <span class="ep-desc">Remove the retention policy and stop the background task</span>
30973 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30974 </div>
30975 <div class="ep-body">
30976 <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>
30977 <details class="schema"><summary>Response</summary>
30978<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
30979 <p class="curl-heading">Example</p>
30980 <div class="curl-wrap">
30981 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
30982 -H "Authorization: Bearer $SLOC_API_KEY" \
30983 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30984 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
30985 </div>
30986 </div>
30987 </div>
30988 </div>
30989
30990 <!-- Scan Profiles -->
30991 <div class="section">
30992 <h2 class="section-title">Scan Profiles</h2>
30993
30994 <div class="ep-card">
30995 <div class="ep-header">
30996 <span class="method get">GET</span>
30997 <span class="ep-path">/api/scan-profiles</span>
30998 <span class="auth-badge protected">Protected</span>
30999 <span class="ep-desc">List saved scan profiles</span>
31000 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31001 </div>
31002 <div class="ep-body">
31003 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31004 <details class="schema"><summary>Response schema</summary>
31005<div class="schema-block">{
31006 "profiles": [{
31007 "id": string, // UUID
31008 "name": string,
31009 "created_at": string, // ISO-8601
31010 "params": object
31011 }]
31012}</div></details>
31013 <p class="curl-heading">Example</p>
31014 <div class="curl-wrap">
31015 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31016 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31017 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31018 </div>
31019 </div>
31020 </div>
31021
31022 <div class="ep-card">
31023 <div class="ep-header">
31024 <span class="method post">POST</span>
31025 <span class="ep-path">/api/scan-profiles</span>
31026 <span class="auth-badge protected">Protected</span>
31027 <span class="ep-desc">Save a scan profile</span>
31028 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31029 </div>
31030 <div class="ep-body">
31031 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31032 <p class="params-heading">Request Body (application/json)</p>
31033 <table class="params">
31034 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31035 <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>
31036 <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>
31037 </table>
31038 <details class="schema"><summary>Response schema</summary>
31039<div class="schema-block">{ "ok": true }</div></details>
31040 <p class="curl-heading">Example</p>
31041 <div class="curl-wrap">
31042 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31043 -H "Authorization: Bearer $SLOC_API_KEY" \
31044 -H "Content-Type: application/json" \
31045 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31046 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31047 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31048 </div>
31049 </div>
31050 </div>
31051
31052 <div class="ep-card">
31053 <div class="ep-header">
31054 <span class="method delete">DELETE</span>
31055 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31056 <span class="auth-badge protected">Protected</span>
31057 <span class="ep-desc">Delete a scan profile</span>
31058 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31059 </div>
31060 <div class="ep-body">
31061 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31062 <p class="params-heading">Path Parameters</p>
31063 <table class="params">
31064 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31065 <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>
31066 </table>
31067 <details class="schema"><summary>Response schema</summary>
31068<div class="schema-block">{ "ok": true }</div></details>
31069 <p class="curl-heading">Example</p>
31070 <div class="curl-wrap">
31071 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31072 -H "Authorization: Bearer $SLOC_API_KEY" \
31073 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31074 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31075 </div>
31076 </div>
31077 </div>
31078 </div>
31079
31080 <!-- Scheduled Scans -->
31081 <div class="section">
31082 <h2 class="section-title">Scheduled Scans</h2>
31083
31084 <div class="ep-card">
31085 <div class="ep-header">
31086 <span class="method get">GET</span>
31087 <span class="ep-path">/api/schedules</span>
31088 <span class="auth-badge protected">Protected</span>
31089 <span class="ep-desc">List configured schedules</span>
31090 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31091 </div>
31092 <div class="ep-body">
31093 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31094 <p class="curl-heading">Example</p>
31095 <div class="curl-wrap">
31096 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31097 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31098 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31099 </div>
31100 </div>
31101 </div>
31102
31103 <div class="ep-card">
31104 <div class="ep-header">
31105 <span class="method post">POST</span>
31106 <span class="ep-path">/api/schedules</span>
31107 <span class="auth-badge protected">Protected</span>
31108 <span class="ep-desc">Create a schedule</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">Creates a new scheduled scan. Use the <a href="/integrations">Integrations UI</a> to configure the full field set interactively.</p>
31113 <p class="curl-heading">Example</p>
31114 <div class="curl-wrap">
31115 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31116 -H "Authorization: Bearer $SLOC_API_KEY" \
31117 -H "Content-Type: application/json" \
31118 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31119 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31120 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31121 </div>
31122 </div>
31123 </div>
31124
31125 <div class="ep-card">
31126 <div class="ep-header">
31127 <span class="method delete">DELETE</span>
31128 <span class="ep-path">/api/schedules</span>
31129 <span class="auth-badge protected">Protected</span>
31130 <span class="ep-desc">Delete a schedule</span>
31131 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31132 </div>
31133 <div class="ep-body">
31134 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31135 <p class="curl-heading">Example</p>
31136 <div class="curl-wrap">
31137 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31138 -H "Authorization: Bearer $SLOC_API_KEY" \
31139 -H "Content-Type: application/json" \
31140 -d '{"id":"<schedule_id>"}' \
31141 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31142 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31143 </div>
31144 </div>
31145 </div>
31146 </div>
31147
31148 <!-- Git Browser -->
31149 <div class="section">
31150 <h2 class="section-title">Git Browser</h2>
31151
31152 <div class="ep-card">
31153 <div class="ep-header">
31154 <span class="method get">GET</span>
31155 <span class="ep-path">/api/git/refs</span>
31156 <span class="auth-badge protected">Protected</span>
31157 <span class="ep-desc">List git refs for a repository</span>
31158 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31159 </div>
31160 <div class="ep-body">
31161 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31162 <p class="params-heading">Query Parameters</p>
31163 <table class="params">
31164 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31165 <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>
31166 </table>
31167 <p class="curl-heading">Example</p>
31168 <div class="curl-wrap">
31169 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31170 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31171 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31172 </div>
31173 </div>
31174 </div>
31175
31176 <div class="ep-card">
31177 <div class="ep-header">
31178 <span class="method get">GET</span>
31179 <span class="ep-path">/api/git/scan-ref</span>
31180 <span class="auth-badge protected">Protected</span>
31181 <span class="ep-desc">SLOC-scan a specific git ref</span>
31182 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31183 </div>
31184 <div class="ep-body">
31185 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31186 <p class="params-heading">Query Parameters</p>
31187 <table class="params">
31188 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31189 <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>
31190 <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>
31191 </table>
31192 <p class="curl-heading">Example</p>
31193 <div class="curl-wrap">
31194 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31195 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
31196 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31197 </div>
31198 </div>
31199 </div>
31200
31201 <div class="ep-card">
31202 <div class="ep-header">
31203 <span class="method get">GET</span>
31204 <span class="ep-path">/api/git/compare-refs</span>
31205 <span class="auth-badge protected">Protected</span>
31206 <span class="ep-desc">Compare SLOC across two git refs</span>
31207 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31208 </div>
31209 <div class="ep-body">
31210 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31211 <p class="params-heading">Query Parameters</p>
31212 <table class="params">
31213 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31214 <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>
31215 <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>
31216 <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>
31217 </table>
31218 <p class="curl-heading">Example</p>
31219 <div class="curl-wrap">
31220 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31221 "<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>
31222 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31223 </div>
31224 </div>
31225 </div>
31226 </div>
31227
31228 <!-- Webhooks -->
31229 <div class="section">
31230 <h2 class="section-title">Webhooks</h2>
31231 <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>
31232
31233 <div class="ep-card">
31234 <div class="ep-header">
31235 <span class="method post">POST</span>
31236 <span class="ep-path">/webhooks/github</span>
31237 <span class="auth-badge hmac">HMAC</span>
31238 <span class="ep-desc">GitHub push event receiver</span>
31239 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31240 </div>
31241 <div class="ep-body">
31242 <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>
31243 <p class="params-heading">Required Headers</p>
31244 <table class="params">
31245 <tr><th>Header</th><th>Value</th></tr>
31246 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31247 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31248 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31249 </table>
31250 </div>
31251 </div>
31252
31253 <div class="ep-card">
31254 <div class="ep-header">
31255 <span class="method post">POST</span>
31256 <span class="ep-path">/webhooks/gitlab</span>
31257 <span class="auth-badge hmac">HMAC</span>
31258 <span class="ep-desc">GitLab push event receiver</span>
31259 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31260 </div>
31261 <div class="ep-body">
31262 <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>
31263 <p class="params-heading">Required Headers</p>
31264 <table class="params">
31265 <tr><th>Header</th><th>Value</th></tr>
31266 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31267 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31268 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31269 </table>
31270 </div>
31271 </div>
31272
31273 <div class="ep-card">
31274 <div class="ep-header">
31275 <span class="method post">POST</span>
31276 <span class="ep-path">/webhooks/bitbucket</span>
31277 <span class="auth-badge hmac">HMAC</span>
31278 <span class="ep-desc">Bitbucket push event receiver</span>
31279 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31280 </div>
31281 <div class="ep-body">
31282 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31283 <p class="params-heading">Required Headers</p>
31284 <table class="params">
31285 <tr><th>Header</th><th>Value</th></tr>
31286 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31287 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31288 </table>
31289 </div>
31290 </div>
31291 </div>
31292
31293 <!-- Config -->
31294 <div class="section">
31295 <h2 class="section-title">Config Import / Export</h2>
31296
31297 <div class="ep-card">
31298 <div class="ep-header">
31299 <span class="method get">GET</span>
31300 <span class="ep-path">/export-config</span>
31301 <span class="auth-badge protected">Protected</span>
31302 <span class="ep-desc">Export server configuration as JSON</span>
31303 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31304 </div>
31305 <div class="ep-body">
31306 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31307 <p class="curl-heading">Example</p>
31308 <div class="curl-wrap">
31309 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31310 -o config.json \
31311 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31312 <button class="curl-copy-btn" data-target="c-export">Copy</button>
31313 </div>
31314 </div>
31315 </div>
31316
31317 <div class="ep-card">
31318 <div class="ep-header">
31319 <span class="method post">POST</span>
31320 <span class="ep-path">/import-config</span>
31321 <span class="auth-badge protected">Protected</span>
31322 <span class="ep-desc">Import server configuration</span>
31323 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31324 </div>
31325 <div class="ep-body">
31326 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31327 <p class="curl-heading">Example</p>
31328 <div class="curl-wrap">
31329 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31330 -H "Authorization: Bearer $SLOC_API_KEY" \
31331 -H "Content-Type: application/json" \
31332 -d @config.json \
31333 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31334 <button class="curl-copy-btn" data-target="c-import">Copy</button>
31335 </div>
31336 </div>
31337 </div>
31338 </div>
31339
31340 <!-- CI Ingest -->
31341 <div class="section">
31342 <h2 class="section-title">CI Ingest</h2>
31343
31344 <div class="ep-card">
31345 <div class="ep-header">
31346 <span class="method post">POST</span>
31347 <span class="ep-path">/api/ingest</span>
31348 <span class="auth-badge protected">Protected</span>
31349 <span class="ep-desc">Push a pre-computed scan result from CI</span>
31350 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31351 </div>
31352 <div class="ep-body">
31353 <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>
31354 <p class="params-heading">Query Parameters</p>
31355 <table class="params">
31356 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31357 <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>
31358 </table>
31359 <p class="params-heading">Request Body (application/json)</p>
31360 <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>
31361 <details class="schema"><summary>Response schema</summary>
31362<div class="schema-block">// 201 Created
31363{
31364 "run_id": string, // UUID of the ingested run
31365 "view_url": string // relative URL to the report page
31366}</div></details>
31367 <p class="curl-heading">Example</p>
31368 <div class="curl-wrap">
31369 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31370 -H "Authorization: Bearer $SLOC_API_KEY" \
31371 -H "Content-Type: application/json" \
31372 -d @result.json \
31373 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31374 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31375 </div>
31376 </div>
31377 </div>
31378 </div>
31379
31380 <!-- Artifact Download -->
31381 <div class="section">
31382 <h2 class="section-title">Artifact Download</h2>
31383
31384 <div class="ep-card">
31385 <div class="ep-header">
31386 <span class="method get">GET</span>
31387 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31388 <span class="auth-badge protected">Protected</span>
31389 <span class="ep-desc">Download or view a scan artifact</span>
31390 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31391 </div>
31392 <div class="ep-body">
31393 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31394 <p class="params-heading">Path Parameters</p>
31395 <table class="params">
31396 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31397 <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>
31398 <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>
31399 </table>
31400 <p class="params-heading">Query Parameters</p>
31401 <table class="params">
31402 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31403 <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>
31404 </table>
31405 <p class="curl-heading">Example — download JSON result</p>
31406 <div class="curl-wrap">
31407 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31408 -o result.json \
31409 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
31410 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31411 </div>
31412 </div>
31413 </div>
31414 </div>
31415
31416 <!-- Embed Widget -->
31417 <div class="section">
31418 <h2 class="section-title">Embed Widget</h2>
31419
31420 <div class="ep-card">
31421 <div class="ep-header">
31422 <span class="method get">GET</span>
31423 <span class="ep-path">/embed/summary</span>
31424 <span class="auth-badge protected">Protected</span>
31425 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31426 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31427 </div>
31428 <div class="ep-body">
31429 <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>
31430 <p class="params-heading">Query Parameters</p>
31431 <table class="params">
31432 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31433 <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>
31434 <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>
31435 </table>
31436 <p class="curl-heading">Example</p>
31437 <div class="curl-wrap">
31438 <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"
31439 width="460" height="260" style="border:none"></iframe></pre>
31440 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31441 </div>
31442 </div>
31443 </div>
31444 </div>
31445
31446 <!-- Confluence Integration -->
31447 <div class="section">
31448 <h2 class="section-title">Confluence Integration</h2>
31449
31450 <div class="ep-card">
31451 <div class="ep-header">
31452 <span class="method get">GET</span>
31453 <span class="ep-path">/api/confluence/config</span>
31454 <span class="auth-badge protected">Protected</span>
31455 <span class="ep-desc">Get current Confluence configuration</span>
31456 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31457 </div>
31458 <div class="ep-body">
31459 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31460 <details class="schema"><summary>Response schema</summary>
31461<div class="schema-block">{
31462 "configured": boolean,
31463 "tier": "cloud" | "server",
31464 "base_url": string,
31465 "username": string,
31466 "api_token_set": boolean,
31467 "space_key": string,
31468 "parent_page_id": string | null,
31469 "schedule_auto_post": { "<schedule_id>": boolean }
31470}</div></details>
31471 <p class="curl-heading">Example</p>
31472 <div class="curl-wrap">
31473 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31474 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31475 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31476 </div>
31477 </div>
31478 </div>
31479
31480 <div class="ep-card">
31481 <div class="ep-header">
31482 <span class="method post">POST</span>
31483 <span class="ep-path">/api/confluence/config</span>
31484 <span class="auth-badge protected">Protected</span>
31485 <span class="ep-desc">Save Confluence configuration</span>
31486 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31487 </div>
31488 <div class="ep-body">
31489 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31490 <p class="params-heading">Request Body (application/json)</p>
31491 <table class="params">
31492 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31493 <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>
31494 <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>
31495 <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>
31496 <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>
31497 <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>
31498 <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>
31499 <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>
31500 </table>
31501 <details class="schema"><summary>Response schema</summary>
31502<div class="schema-block">{ "ok": true }</div></details>
31503 <p class="curl-heading">Example</p>
31504 <div class="curl-wrap">
31505 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31506 -H "Authorization: Bearer $SLOC_API_KEY" \
31507 -H "Content-Type: application/json" \
31508 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31509 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31510 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31511 </div>
31512 </div>
31513 </div>
31514
31515 <div class="ep-card">
31516 <div class="ep-header">
31517 <span class="method post">POST</span>
31518 <span class="ep-path">/api/confluence/test</span>
31519 <span class="auth-badge protected">Protected</span>
31520 <span class="ep-desc">Test Confluence connection</span>
31521 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31522 </div>
31523 <div class="ep-body">
31524 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31525 <details class="schema"><summary>Response schema</summary>
31526<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31527 <p class="curl-heading">Example</p>
31528 <div class="curl-wrap">
31529 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31530 -H "Authorization: Bearer $SLOC_API_KEY" \
31531 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31532 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31533 </div>
31534 </div>
31535 </div>
31536
31537 <div class="ep-card">
31538 <div class="ep-header">
31539 <span class="method post">POST</span>
31540 <span class="ep-path">/api/confluence/post</span>
31541 <span class="auth-badge protected">Protected</span>
31542 <span class="ep-desc">Publish a scan report to Confluence</span>
31543 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31544 </div>
31545 <div class="ep-body">
31546 <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>
31547 <p class="params-heading">Request Body (application/json)</p>
31548 <table class="params">
31549 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31550 <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>
31551 <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>
31552 <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>
31553 </table>
31554 <details class="schema"><summary>Response schema</summary>
31555<div class="schema-block">// 200 OK
31556{ "ok": true, "page_id": string }
31557
31558// 400 / 502 on error
31559{ "ok": false, "error": string }</div></details>
31560 <p class="curl-heading">Example</p>
31561 <div class="curl-wrap">
31562 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31563 -H "Authorization: Bearer $SLOC_API_KEY" \
31564 -H "Content-Type: application/json" \
31565 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
31566 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31567 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31568 </div>
31569 </div>
31570 </div>
31571
31572 <div class="ep-card">
31573 <div class="ep-header">
31574 <span class="method get">GET</span>
31575 <span class="ep-path">/api/confluence/wiki-markup</span>
31576 <span class="auth-badge protected">Protected</span>
31577 <span class="ep-desc">Get Confluence wiki markup for a run</span>
31578 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31579 </div>
31580 <div class="ep-body">
31581 <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>
31582 <p class="params-heading">Query Parameters</p>
31583 <table class="params">
31584 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31585 <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>
31586 </table>
31587 <p class="curl-heading">Example</p>
31588 <div class="curl-wrap">
31589 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31590 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
31591 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31592 </div>
31593 </div>
31594 </div>
31595 </div>
31596
31597 <!-- Authentication -->
31598 <div class="section">
31599 <h2 class="section-title">Authentication</h2>
31600 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31601
31602 <div class="ep-card">
31603 <div class="ep-header">
31604 <span class="method get">GET</span>
31605 <span class="ep-path">/auth/login</span>
31606 <span class="auth-badge public">Public</span>
31607 <span class="ep-desc">Login page</span>
31608 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31609 </div>
31610 <div class="ep-body">
31611 <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>
31612 <p class="params-heading">Query Parameters</p>
31613 <table class="params">
31614 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31615 <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>
31616 <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>
31617 </table>
31618 </div>
31619 </div>
31620
31621 <div class="ep-card">
31622 <div class="ep-header">
31623 <span class="method post">POST</span>
31624 <span class="ep-path">/auth/login</span>
31625 <span class="auth-badge public">Public</span>
31626 <span class="ep-desc">Submit credentials and get a session cookie</span>
31627 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31628 </div>
31629 <div class="ep-body">
31630 <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>
31631 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31632 <table class="params">
31633 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31634 <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>
31635 <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>
31636 </table>
31637 <p class="curl-heading">Example</p>
31638 <div class="curl-wrap">
31639 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31640 -d "key=$SLOC_API_KEY&next=/" \
31641 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31642 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31643 </div>
31644 </div>
31645 </div>
31646 </div>
31647
31648 <!-- Coverage Suggestion -->
31649 <div class="section">
31650 <h2 class="section-title">Coverage Suggestion</h2>
31651
31652 <div class="ep-card">
31653 <div class="ep-header">
31654 <span class="method get">GET</span>
31655 <span class="ep-path">/api/suggest-coverage</span>
31656 <span class="auth-badge protected">Protected</span>
31657 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31658 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31659 </div>
31660 <div class="ep-body">
31661 <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>
31662 <p class="params-heading">Query Parameters</p>
31663 <table class="params">
31664 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31665 <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>
31666 </table>
31667 <details class="schema"><summary>Response schema</summary>
31668<div class="schema-block">{
31669 "found": string | null, // absolute path to the coverage file, if detected
31670 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31671 "hint": string | null // shell command to generate coverage if not found
31672}</div></details>
31673 <p class="curl-heading">Example</p>
31674 <div class="curl-wrap">
31675 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31676 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31677 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31678 </div>
31679 </div>
31680 </div>
31681 </div>
31682
31683 </div>
31684
31685 <footer class="site-footer">
31686 local code analysis - metrics, history and reports
31687 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
31688 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31689 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31690 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31691 · <a href="/api-docs" rel="noopener">REST API</a>
31692 </footer>
31693
31694 <script nonce="{{ csp_nonce }}">
31695 (function () {
31696 var base = window.location.origin;
31697 document.getElementById('base-url').textContent = base;
31698 document.querySelectorAll('.base-url-slot').forEach(function (el) {
31699 el.textContent = base;
31700 });
31701
31702 document.querySelectorAll('.ep-header').forEach(function (hdr) {
31703 hdr.addEventListener('click', function () {
31704 hdr.closest('.ep-card').classList.toggle('open');
31705 });
31706 });
31707
31708 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31709 btn.addEventListener('click', function () {
31710 var targetId = btn.dataset.target;
31711 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31712 if (!pre) return;
31713 navigator.clipboard.writeText(pre.textContent).then(function () {
31714 btn.textContent = 'Copied!';
31715 btn.classList.add('copied');
31716 setTimeout(function () {
31717 btn.textContent = 'Copy';
31718 btn.classList.remove('copied');
31719 }, 2000);
31720 });
31721 });
31722 });
31723
31724 var storageKey = 'oxide-sloc-theme';
31725 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31726 var themeBtn = document.getElementById('theme-toggle');
31727 if (themeBtn) {
31728 themeBtn.addEventListener('click', function () {
31729 var dark = document.body.classList.toggle('dark-theme');
31730 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31731 });
31732 }
31733 (function() {
31734 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'}];
31735 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);});}
31736 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31737 var btn=document.getElementById('settings-btn');if(!btn)return;
31738 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31739 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>';
31740 document.body.appendChild(m);
31741 var g=document.getElementById('scheme-grid');
31742 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);});
31743 var cl=document.getElementById('settings-close');
31744 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);
31745 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');});
31746 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31747 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31748 })();
31749 (function randomizeWatermarks() {
31750 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31751 if (!wms.length) return;
31752 var placed = [];
31753 function tooClose(top, left) {
31754 for (var i = 0; i < placed.length; i++) {
31755 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31756 if (dt < 16 && dl < 12) return true;
31757 }
31758 return false;
31759 }
31760 function pick(leftBand) {
31761 for (var attempt = 0; attempt < 50; attempt++) {
31762 var top = Math.random() * 88 + 2;
31763 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31764 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31765 }
31766 var top = Math.random() * 88 + 2;
31767 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31768 placed.push([top, left]); return [top, left];
31769 }
31770 var half = Math.floor(wms.length / 2);
31771 wms.forEach(function (img, i) {
31772 var pos = pick(i < half);
31773 var size = Math.floor(Math.random() * 100 + 120);
31774 var rot = (Math.random() * 360).toFixed(1);
31775 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31776 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;
31777 });
31778 })();
31779 (function spawnCodeParticles() {
31780 var container = document.getElementById('code-particles');
31781 if (!container) return;
31782 var snippets = [
31783 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31784 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31785 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31786 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31787 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31788 ];
31789 var count = 38;
31790 for (var i = 0; i < count; i++) {
31791 (function(idx) {
31792 var el = document.createElement('span');
31793 el.className = 'code-particle';
31794 el.textContent = snippets[idx % snippets.length];
31795 var left = Math.random() * 94 + 2;
31796 var top = Math.random() * 88 + 6;
31797 var dur = (Math.random() * 10 + 9).toFixed(1);
31798 var delay = (Math.random() * 18).toFixed(1);
31799 var rot = (Math.random() * 26 - 13).toFixed(1);
31800 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31801 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31802 container.appendChild(el);
31803 })(i);
31804 }
31805 })();
31806 }());
31807 </script>
31808</body>
31809</html>
31810"##,
31811 ext = "html"
31812)]
31813struct ApiDocsTemplate {
31814 has_api_key: bool,
31815 csp_nonce: String,
31816 version: &'static str,
31817}
31818
31819#[cfg(test)]
31820mod form_config_tests {
31821 use super::*;
31822 use sloc_config::{
31823 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31824 };
31825
31826 fn blank_form() -> AnalyzeForm {
31827 AnalyzeForm {
31828 path: ".".to_string(),
31829 git_repo: None,
31830 git_ref: None,
31831 mixed_line_policy: None,
31832 python_docstrings_as_comments: None,
31833 generated_file_detection: None,
31834 minified_file_detection: None,
31835 vendor_directory_detection: None,
31836 include_lockfiles: None,
31837 binary_file_behavior: None,
31838 output_dir: None,
31839 report_title: None,
31840 report_header_footer: None,
31841 include_globs: None,
31842 exclude_globs: None,
31843 submodule_breakdown: None,
31844 coverage_file: None,
31845 continuation_line_policy: None,
31846 blank_in_block_comment_policy: None,
31847 count_compiler_directives: None,
31848 style_col_threshold: None,
31849 style_analysis_enabled: None,
31850 style_score_threshold: None,
31851 style_lang_scope: None,
31852 cocomo_mode: None,
31853 complexity_alert: None,
31854 exclude_duplicates: None,
31855 activity_window: None,
31856 }
31857 }
31858
31859 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31860 let mut cfg = sloc_config::AppConfig::default();
31861 apply_form_to_config(&mut cfg, form);
31862 cfg
31863 }
31864
31865 #[test]
31868 fn extract_long_commit_picks_super_repo_by_short_prefix() {
31869 let dir = tempfile::tempdir().unwrap();
31873 let path = dir.path().join("result.json");
31874 let body = r#"{
31875 "submodules": [
31876 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31877 { "git_commit_long": null }
31878 ],
31879 "git_commit_short": "4c2cd9b",
31880 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31881}"#;
31882 std::fs::write(&path, body).unwrap();
31883 assert_eq!(
31884 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31885 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31886 );
31887 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31889 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31890 }
31891
31892 #[test]
31893 fn activity_window_defaults_on_when_field_blank() {
31894 let cfg = apply(&blank_form());
31896 assert_eq!(cfg.analysis.activity_window_days, Some(90));
31897 }
31898
31899 #[test]
31900 fn activity_window_override_sets_days() {
31901 let mut form = blank_form();
31902 form.activity_window = Some("30".to_string());
31903 let cfg = apply(&form);
31904 assert_eq!(cfg.analysis.activity_window_days, Some(30));
31905 }
31906
31907 #[test]
31908 fn activity_window_zero_disables() {
31909 let mut form = blank_form();
31911 form.activity_window = Some("0".to_string());
31912 let cfg = apply(&form);
31913 assert_eq!(cfg.analysis.activity_window_days, Some(0));
31914 }
31915
31916 #[test]
31919 fn python_docstrings_false_when_unchecked() {
31920 let cfg = apply(&blank_form());
31922 assert!(
31923 !cfg.analysis.python_docstrings_as_comments,
31924 "absent python_docstrings_as_comments must map to false"
31925 );
31926 }
31927
31928 #[test]
31929 fn python_docstrings_true_when_checked() {
31930 let mut form = blank_form();
31932 form.python_docstrings_as_comments = Some("on".to_string());
31933 let cfg = apply(&form);
31934 assert!(cfg.analysis.python_docstrings_as_comments);
31935 }
31936
31937 #[test]
31938 fn python_docstrings_true_for_any_non_none_value() {
31939 let mut form = blank_form();
31941 form.python_docstrings_as_comments = Some("true".to_string());
31942 assert!(apply(&form).analysis.python_docstrings_as_comments);
31943 }
31944
31945 #[test]
31948 fn submodule_breakdown_false_when_unchecked() {
31949 let cfg = apply(&blank_form());
31950 assert!(
31951 !cfg.discovery.submodule_breakdown,
31952 "absent submodule_breakdown must map to false"
31953 );
31954 }
31955
31956 #[test]
31957 fn submodule_breakdown_true_when_value_enabled() {
31958 let mut form = blank_form();
31959 form.submodule_breakdown = Some("enabled".to_string());
31960 assert!(apply(&form).discovery.submodule_breakdown);
31961 }
31962
31963 #[test]
31964 fn submodule_breakdown_false_for_wrong_value() {
31965 let mut form = blank_form();
31967 form.submodule_breakdown = Some("on".to_string());
31968 assert!(
31969 !apply(&form).discovery.submodule_breakdown,
31970 "submodule_breakdown only becomes true for the exact value 'enabled'"
31971 );
31972 }
31973
31974 #[test]
31977 fn generated_detection_true_when_enabled() {
31978 let mut form = blank_form();
31979 form.generated_file_detection = Some("enabled".to_string());
31980 assert!(apply(&form).analysis.generated_file_detection);
31981 }
31982
31983 #[test]
31984 fn generated_detection_false_when_disabled() {
31985 let mut form = blank_form();
31986 form.generated_file_detection = Some("disabled".to_string());
31987 assert!(!apply(&form).analysis.generated_file_detection);
31988 }
31989
31990 #[test]
31991 fn generated_detection_true_when_absent() {
31992 assert!(
31994 apply(&blank_form()).analysis.generated_file_detection,
31995 "absent field must default to true (detection on)"
31996 );
31997 }
31998
31999 #[test]
32002 fn minified_detection_false_when_disabled() {
32003 let mut form = blank_form();
32004 form.minified_file_detection = Some("disabled".to_string());
32005 assert!(!apply(&form).analysis.minified_file_detection);
32006 }
32007
32008 #[test]
32009 fn minified_detection_true_when_enabled() {
32010 let mut form = blank_form();
32011 form.minified_file_detection = Some("enabled".to_string());
32012 assert!(apply(&form).analysis.minified_file_detection);
32013 }
32014
32015 #[test]
32016 fn minified_detection_true_when_absent() {
32017 assert!(apply(&blank_form()).analysis.minified_file_detection);
32018 }
32019
32020 #[test]
32023 fn vendor_detection_false_when_disabled() {
32024 let mut form = blank_form();
32025 form.vendor_directory_detection = Some("disabled".to_string());
32026 assert!(!apply(&form).analysis.vendor_directory_detection);
32027 }
32028
32029 #[test]
32030 fn vendor_detection_true_when_enabled() {
32031 let mut form = blank_form();
32032 form.vendor_directory_detection = Some("enabled".to_string());
32033 assert!(apply(&form).analysis.vendor_directory_detection);
32034 }
32035
32036 #[test]
32037 fn vendor_detection_true_when_absent() {
32038 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32039 }
32040
32041 #[test]
32044 fn lockfiles_false_when_absent() {
32045 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32047 }
32048
32049 #[test]
32050 fn lockfiles_false_when_disabled() {
32051 let mut form = blank_form();
32052 form.include_lockfiles = Some("disabled".to_string());
32053 assert!(!apply(&form).analysis.include_lockfiles);
32054 }
32055
32056 #[test]
32057 fn lockfiles_true_when_enabled() {
32058 let mut form = blank_form();
32059 form.include_lockfiles = Some("enabled".to_string());
32060 assert!(apply(&form).analysis.include_lockfiles);
32061 }
32062
32063 #[test]
32066 fn compiler_directives_true_when_absent() {
32067 assert!(
32068 apply(&blank_form()).analysis.count_compiler_directives,
32069 "absent count_compiler_directives must default to true"
32070 );
32071 }
32072
32073 #[test]
32074 fn compiler_directives_true_when_enabled() {
32075 let mut form = blank_form();
32076 form.count_compiler_directives = Some("enabled".to_string());
32077 assert!(apply(&form).analysis.count_compiler_directives);
32078 }
32079
32080 #[test]
32081 fn compiler_directives_false_when_disabled() {
32082 let mut form = blank_form();
32083 form.count_compiler_directives = Some("disabled".to_string());
32084 assert!(!apply(&form).analysis.count_compiler_directives);
32085 }
32086
32087 #[test]
32090 fn mixed_policy_unchanged_when_absent() {
32091 assert_eq!(
32093 apply(&blank_form()).analysis.mixed_line_policy,
32094 MixedLinePolicy::CodeOnly
32095 );
32096 }
32097
32098 #[test]
32099 fn mixed_policy_code_only() {
32100 let mut form = blank_form();
32101 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32102 assert_eq!(
32103 apply(&form).analysis.mixed_line_policy,
32104 MixedLinePolicy::CodeOnly
32105 );
32106 }
32107
32108 #[test]
32109 fn mixed_policy_code_and_comment() {
32110 let mut form = blank_form();
32111 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32112 assert_eq!(
32113 apply(&form).analysis.mixed_line_policy,
32114 MixedLinePolicy::CodeAndComment
32115 );
32116 }
32117
32118 #[test]
32119 fn mixed_policy_comment_only() {
32120 let mut form = blank_form();
32121 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32122 assert_eq!(
32123 apply(&form).analysis.mixed_line_policy,
32124 MixedLinePolicy::CommentOnly
32125 );
32126 }
32127
32128 #[test]
32129 fn mixed_policy_separate_mixed_category() {
32130 let mut form = blank_form();
32131 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32132 assert_eq!(
32133 apply(&form).analysis.mixed_line_policy,
32134 MixedLinePolicy::SeparateMixedCategory
32135 );
32136 }
32137
32138 #[test]
32141 fn binary_behavior_skip_when_absent() {
32142 assert_eq!(
32143 apply(&blank_form()).analysis.binary_file_behavior,
32144 BinaryFileBehavior::Skip
32145 );
32146 }
32147
32148 #[test]
32149 fn binary_behavior_skip() {
32150 let mut form = blank_form();
32151 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32152 assert_eq!(
32153 apply(&form).analysis.binary_file_behavior,
32154 BinaryFileBehavior::Skip
32155 );
32156 }
32157
32158 #[test]
32159 fn binary_behavior_fail() {
32160 let mut form = blank_form();
32161 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32162 assert_eq!(
32163 apply(&form).analysis.binary_file_behavior,
32164 BinaryFileBehavior::Fail
32165 );
32166 }
32167
32168 #[test]
32171 fn continuation_policy_each_physical_when_absent() {
32172 assert_eq!(
32173 apply(&blank_form()).analysis.continuation_line_policy,
32174 ContinuationLinePolicy::EachPhysicalLine
32175 );
32176 }
32177
32178 #[test]
32179 fn continuation_policy_collapse_to_logical() {
32180 let mut form = blank_form();
32181 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32182 assert_eq!(
32183 apply(&form).analysis.continuation_line_policy,
32184 ContinuationLinePolicy::CollapseToLogical
32185 );
32186 }
32187
32188 #[test]
32191 fn blank_in_block_comment_count_as_comment_when_absent() {
32192 assert_eq!(
32193 apply(&blank_form()).analysis.blank_in_block_comment_policy,
32194 BlankInBlockCommentPolicy::CountAsComment
32195 );
32196 }
32197
32198 #[test]
32199 fn blank_in_block_comment_count_as_blank() {
32200 let mut form = blank_form();
32201 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32202 assert_eq!(
32203 apply(&form).analysis.blank_in_block_comment_policy,
32204 BlankInBlockCommentPolicy::CountAsBlank
32205 );
32206 }
32207
32208 #[test]
32211 fn style_threshold_80() {
32212 let mut form = blank_form();
32213 form.style_col_threshold = Some("80".to_string());
32214 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32215 }
32216
32217 #[test]
32218 fn style_threshold_100() {
32219 let mut form = blank_form();
32220 form.style_col_threshold = Some("100".to_string());
32221 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32222 }
32223
32224 #[test]
32225 fn style_threshold_120() {
32226 let mut form = blank_form();
32227 form.style_col_threshold = Some("120".to_string());
32228 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32229 }
32230
32231 #[test]
32232 fn style_threshold_invalid_value_leaves_default() {
32233 let mut cfg = sloc_config::AppConfig::default();
32235 let mut form = blank_form();
32236 form.style_col_threshold = Some("42".to_string());
32237 apply_form_to_config(&mut cfg, &form);
32238 assert_eq!(
32239 cfg.analysis.style_col_threshold, 80,
32240 "invalid threshold must not change config"
32241 );
32242 }
32243
32244 #[test]
32245 fn style_threshold_non_numeric_leaves_default() {
32246 let mut cfg = sloc_config::AppConfig::default();
32247 let mut form = blank_form();
32248 form.style_col_threshold = Some("large".to_string());
32249 apply_form_to_config(&mut cfg, &form);
32250 assert_eq!(cfg.analysis.style_col_threshold, 80);
32251 }
32252
32253 #[test]
32254 fn style_threshold_zero_leaves_default() {
32255 let mut cfg = sloc_config::AppConfig::default();
32256 let mut form = blank_form();
32257 form.style_col_threshold = Some("0".to_string());
32258 apply_form_to_config(&mut cfg, &form);
32259 assert_eq!(cfg.analysis.style_col_threshold, 80);
32260 }
32261
32262 #[test]
32263 fn style_threshold_absent_leaves_default() {
32264 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32265 }
32266
32267 #[test]
32270 fn style_score_threshold_zero_when_absent() {
32271 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32272 }
32273
32274 #[test]
32275 fn style_score_threshold_set_to_valid_value() {
32276 let mut form = blank_form();
32277 form.style_score_threshold = Some("70".to_string());
32278 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32279 }
32280
32281 #[test]
32282 fn style_score_threshold_clamps_to_100_when_over() {
32283 let mut form = blank_form();
32285 form.style_score_threshold = Some("200".to_string());
32286 assert_eq!(
32287 apply(&form).analysis.style_score_threshold,
32288 100,
32289 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32290 );
32291 }
32292
32293 #[test]
32296 fn coverage_file_none_when_absent() {
32297 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32298 }
32299
32300 #[test]
32301 fn coverage_file_none_when_whitespace_only() {
32302 let mut form = blank_form();
32303 form.coverage_file = Some(" ".to_string());
32304 assert!(
32305 apply(&form).analysis.coverage_file.is_none(),
32306 "whitespace-only coverage_file must be treated as None"
32307 );
32308 }
32309
32310 #[test]
32311 fn coverage_file_set_when_non_empty() {
32312 let mut form = blank_form();
32313 form.coverage_file = Some("coverage/lcov.info".to_string());
32314 assert_eq!(
32315 apply(&form).analysis.coverage_file,
32316 Some(std::path::PathBuf::from("coverage/lcov.info"))
32317 );
32318 }
32319
32320 #[test]
32321 fn coverage_file_trims_whitespace() {
32322 let mut form = blank_form();
32323 form.coverage_file = Some(" coverage/lcov.info ".to_string());
32324 assert_eq!(
32325 apply(&form).analysis.coverage_file,
32326 Some(std::path::PathBuf::from("coverage/lcov.info"))
32327 );
32328 }
32329
32330 #[test]
32333 fn report_title_unchanged_when_absent() {
32334 let original = sloc_config::AppConfig::default().reporting.report_title;
32335 assert_eq!(apply(&blank_form()).reporting.report_title, original);
32336 }
32337
32338 #[test]
32339 fn report_title_unchanged_when_whitespace_only() {
32340 let original = sloc_config::AppConfig::default().reporting.report_title;
32341 let mut form = blank_form();
32342 form.report_title = Some(" ".to_string());
32343 assert_eq!(
32344 apply(&form).reporting.report_title,
32345 original,
32346 "whitespace-only title must not overwrite the default"
32347 );
32348 }
32349
32350 #[test]
32351 fn report_title_updated_and_trimmed() {
32352 let mut form = blank_form();
32353 form.report_title = Some(" My Project ".to_string());
32354 assert_eq!(apply(&form).reporting.report_title, "My Project");
32355 }
32356
32357 #[test]
32360 fn header_footer_none_when_absent() {
32361 assert!(apply(&blank_form())
32362 .reporting
32363 .report_header_footer
32364 .is_none());
32365 }
32366
32367 #[test]
32368 fn header_footer_none_when_whitespace_only() {
32369 let mut form = blank_form();
32370 form.report_header_footer = Some(" ".to_string());
32371 assert!(apply(&form).reporting.report_header_footer.is_none());
32372 }
32373
32374 #[test]
32375 fn header_footer_set_and_trimmed() {
32376 let mut form = blank_form();
32377 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
32378 assert_eq!(
32379 apply(&form).reporting.report_header_footer,
32380 Some("Confidential — Internal Use".to_string())
32381 );
32382 }
32383
32384 #[test]
32387 fn include_globs_empty_when_absent() {
32388 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32389 }
32390
32391 #[test]
32392 fn include_globs_newline_separated() {
32393 let mut form = blank_form();
32394 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32395 assert_eq!(
32396 apply(&form).discovery.include_globs,
32397 vec!["src/**/*.rs", "tests/**/*.rs"]
32398 );
32399 }
32400
32401 #[test]
32402 fn exclude_globs_comma_separated() {
32403 let mut form = blank_form();
32404 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32405 assert_eq!(
32406 apply(&form).discovery.exclude_globs,
32407 vec!["vendor/**", "node_modules/**"]
32408 );
32409 }
32410
32411 #[test]
32412 fn globs_mixed_separators() {
32413 let mut form = blank_form();
32414 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32415 assert_eq!(
32416 apply(&form).discovery.exclude_globs,
32417 vec!["a/**", "b/**", "c/**"]
32418 );
32419 }
32420
32421 #[test]
32424 fn split_patterns_none_is_empty() {
32425 assert!(split_patterns(None).is_empty());
32426 }
32427
32428 #[test]
32429 fn split_patterns_empty_string_is_empty() {
32430 assert!(split_patterns(Some("")).is_empty());
32431 }
32432
32433 #[test]
32434 fn split_patterns_whitespace_only_is_empty() {
32435 assert!(split_patterns(Some(" \n \n ")).is_empty());
32436 }
32437
32438 #[test]
32439 fn split_patterns_newlines() {
32440 assert_eq!(
32441 split_patterns(Some("a/**\nb/**\nc/**")),
32442 vec!["a/**", "b/**", "c/**"]
32443 );
32444 }
32445
32446 #[test]
32447 fn split_patterns_commas() {
32448 assert_eq!(
32449 split_patterns(Some("a/**,b/**,c/**")),
32450 vec!["a/**", "b/**", "c/**"]
32451 );
32452 }
32453
32454 #[test]
32455 fn split_patterns_mixed() {
32456 assert_eq!(
32457 split_patterns(Some("a/**\nb/**,c/**")),
32458 vec!["a/**", "b/**", "c/**"]
32459 );
32460 }
32461
32462 #[test]
32463 fn split_patterns_trims_whitespace() {
32464 assert_eq!(
32465 split_patterns(Some(" a/** \n b/** ")),
32466 vec!["a/**", "b/**"]
32467 );
32468 }
32469
32470 #[test]
32471 fn split_patterns_filters_empty_entries() {
32472 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32473 }
32474
32475 #[test]
32476 fn split_patterns_single_entry() {
32477 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32478 }
32479}
32480
32481#[cfg(test)]
32482mod utility_tests {
32483 use super::*;
32484 use std::net::IpAddr;
32485 use std::time::Duration;
32486
32487 #[test]
32490 fn sanitize_simple_name() {
32491 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32492 }
32493
32494 #[test]
32495 fn sanitize_uppercased_lowercased() {
32496 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32497 }
32498
32499 #[test]
32500 fn sanitize_path_extracts_filename() {
32501 assert_eq!(
32502 sanitize_project_label("/home/user/my-project"),
32503 "my-project"
32504 );
32505 }
32506
32507 #[test]
32508 fn sanitize_path_uses_last_component() {
32509 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32510 }
32511
32512 #[test]
32513 fn sanitize_spaces_become_hyphens() {
32514 assert_eq!(sanitize_project_label("my project"), "my-project");
32515 }
32516
32517 #[test]
32518 fn sanitize_non_ascii_become_hyphens() {
32519 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32520 }
32521
32522 #[test]
32523 fn sanitize_all_special_chars_gives_project() {
32524 assert_eq!(sanitize_project_label("!@#$%^"), "project");
32525 }
32526
32527 #[test]
32528 fn sanitize_empty_string_gives_project() {
32529 assert_eq!(sanitize_project_label(""), "project");
32530 }
32531
32532 #[test]
32533 fn sanitize_leading_trailing_hyphens_stripped() {
32534 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32535 }
32536
32537 #[test]
32538 fn sanitize_alphanumeric_preserved() {
32539 assert_eq!(sanitize_project_label("repo123"), "repo123");
32540 }
32541
32542 #[test]
32543 fn sanitize_dots_become_hyphens() {
32544 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32545 }
32546
32547 #[test]
32548 fn sanitize_mixed_slashes_uses_filename() {
32549 assert_eq!(sanitize_project_label("project-name"), "project-name");
32551 }
32552
32553 #[test]
32556 fn rate_limiter_allows_first_request() {
32557 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32558 let ip: IpAddr = "127.0.0.1".parse().unwrap();
32559 assert!(rl.is_allowed(ip));
32560 }
32561
32562 #[test]
32563 fn rate_limiter_blocks_after_limit_reached() {
32564 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32565 let ip: IpAddr = "10.0.0.1".parse().unwrap();
32566 assert!(rl.is_allowed(ip));
32567 assert!(rl.is_allowed(ip));
32568 assert!(rl.is_allowed(ip));
32569 assert!(!rl.is_allowed(ip), "4th request must be blocked");
32570 }
32571
32572 #[test]
32573 fn rate_limiter_allows_requests_up_to_limit() {
32574 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32575 let ip: IpAddr = "10.0.0.2".parse().unwrap();
32576 for _ in 0..5 {
32577 assert!(rl.is_allowed(ip));
32578 }
32579 assert!(!rl.is_allowed(ip), "6th request must be blocked");
32580 }
32581
32582 #[test]
32583 fn rate_limiter_different_ips_are_independent() {
32584 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32585 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32586 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32587 assert!(rl.is_allowed(ip1));
32588 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32589 assert!(rl.is_allowed(ip2), "ip2 must be independent");
32590 }
32591
32592 #[test]
32593 fn rate_limiter_auth_failure_not_locked_below_threshold() {
32594 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32595 let ip: IpAddr = "10.0.0.3".parse().unwrap();
32596 rl.record_auth_failure(ip);
32597 rl.record_auth_failure(ip);
32598 assert!(
32599 !rl.is_auth_locked_out(ip),
32600 "not locked at 2 failures when threshold is 3"
32601 );
32602 }
32603
32604 #[test]
32605 fn rate_limiter_auth_failure_locked_at_threshold() {
32606 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32607 let ip: IpAddr = "10.0.0.4".parse().unwrap();
32608 rl.record_auth_failure(ip);
32609 rl.record_auth_failure(ip);
32610 rl.record_auth_failure(ip);
32611 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32612 }
32613
32614 #[test]
32615 fn rate_limiter_auth_failure_different_ips_independent() {
32616 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32617 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32618 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32619 rl.record_auth_failure(ip1);
32620 rl.record_auth_failure(ip1);
32621 assert!(rl.is_auth_locked_out(ip1));
32622 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32623 }
32624
32625 #[test]
32626 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32627 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32628 let ip: IpAddr = "127.0.0.2".parse().unwrap();
32629 for _ in 0..100 {
32630 assert!(rl.is_allowed(ip));
32631 }
32632 }
32633
32634 #[test]
32637 fn strip_unc_plain_path_unchanged() {
32638 let p = PathBuf::from("C:\\Users\\user\\project");
32639 let result = strip_unc_prefix(p.clone());
32640 assert_eq!(result, p);
32641 }
32642
32643 #[test]
32644 fn strip_unc_with_drive_prefix_stripped() {
32645 let p = PathBuf::from(r"\\?\C:\Users\user\project");
32646 let result = strip_unc_prefix(p);
32647 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32648 }
32649
32650 #[test]
32651 fn strip_unc_with_network_prefix_stripped() {
32652 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32653 let result = strip_unc_prefix(p);
32654 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32655 }
32656
32657 #[test]
32658 fn strip_unc_linux_path_unchanged() {
32659 let p = PathBuf::from("/home/user/project");
32660 let result = strip_unc_prefix(p.clone());
32661 assert_eq!(result, p);
32662 }
32663
32664 #[test]
32667 fn remote_to_commit_url_github_https() {
32668 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32669 assert_eq!(
32670 url,
32671 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32672 );
32673 }
32674
32675 #[test]
32676 fn remote_to_commit_url_github_ssh() {
32677 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32678 assert_eq!(
32679 url,
32680 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32681 );
32682 }
32683
32684 #[test]
32685 fn remote_to_commit_url_gitlab_uses_dash_commit() {
32686 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32687 assert_eq!(
32688 url,
32689 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32690 );
32691 }
32692
32693 #[test]
32694 fn remote_to_commit_url_bitbucket_uses_commits() {
32695 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32696 assert_eq!(
32697 url,
32698 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32699 );
32700 }
32701
32702 #[test]
32703 fn remote_to_commit_url_unknown_scheme_returns_none() {
32704 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32705 assert!(url.is_none());
32706 }
32707
32708 #[test]
32709 fn remote_to_commit_url_ssh_gitlab() {
32710 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32711 assert!(url.is_some());
32712 let u = url.unwrap();
32713 assert!(
32714 u.contains("/-/commit/sha123"),
32715 "gitlab ssh must use /-/commit/"
32716 );
32717 }
32718
32719 #[test]
32722 fn git_clone_dest_github_url_produces_safe_name() {
32723 let dir = PathBuf::from("/tmp/clones");
32724 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32725 let name = dest.file_name().unwrap().to_string_lossy();
32726 assert!(!name.is_empty());
32727 assert!(
32728 name.chars()
32729 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32730 "clone dest must only contain safe chars, got: {name}"
32731 );
32732 }
32733
32734 #[test]
32735 fn git_clone_dest_is_inside_clones_dir() {
32736 let dir = PathBuf::from("/tmp/clones");
32737 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32738 assert!(
32739 dest.starts_with(&dir),
32740 "clone dest must be inside clones_dir"
32741 );
32742 }
32743
32744 #[test]
32745 fn git_clone_dest_truncates_to_80_chars_max() {
32746 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32747 let dir = PathBuf::from("/tmp/clones");
32748 let dest = git_clone_dest(&long_url, &dir);
32749 let name = dest.file_name().unwrap().to_string_lossy();
32750 assert!(
32751 name.len() <= 80,
32752 "clone dest name must be at most 80 chars, got {} chars: {name}",
32753 name.len()
32754 );
32755 }
32756
32757 #[test]
32758 fn git_clone_dest_special_chars_replaced_with_underscore() {
32759 let dir = PathBuf::from("/tmp/clones");
32760 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32761 let name = dest.file_name().unwrap().to_string_lossy();
32762 assert!(
32763 !name.contains('@') && !name.contains(':') && !name.contains('/'),
32764 "special chars must be replaced in clone dest, got: {name}"
32765 );
32766 }
32767
32768 #[test]
32769 fn git_clone_dest_different_urls_differ() {
32770 let dir = PathBuf::from("/tmp/clones");
32771 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32772 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32773 assert_ne!(
32774 a, b,
32775 "different repos must produce different clone dest names"
32776 );
32777 }
32778
32779 #[test]
32780 fn git_clone_dest_same_url_same_result() {
32781 let dir = PathBuf::from("/tmp/clones");
32782 let url = "https://github.com/owner/repo.git";
32783 assert_eq!(
32784 git_clone_dest(url, &dir),
32785 git_clone_dest(url, &dir),
32786 "same URL must always give same clone dest"
32787 );
32788 }
32789
32790 #[test]
32793 fn fmt_delta_positive_has_plus_prefix() {
32794 assert_eq!(fmt_delta(5), "+5");
32795 }
32796
32797 #[test]
32798 fn fmt_delta_negative_no_plus_prefix() {
32799 assert_eq!(fmt_delta(-3), "-3");
32800 }
32801
32802 #[test]
32803 fn fmt_delta_zero() {
32804 assert_eq!(fmt_delta(0), "0");
32805 }
32806
32807 #[test]
32810 fn delta_class_positive_is_pos() {
32811 assert_eq!(delta_class(1), "pos");
32812 }
32813
32814 #[test]
32815 fn delta_class_negative_is_neg() {
32816 assert_eq!(delta_class(-1), "neg");
32817 }
32818
32819 #[test]
32820 fn delta_class_zero_is_zero_class() {
32821 assert_eq!(delta_class(0), "zero");
32822 }
32823
32824 #[test]
32827 fn fmt_pct_zero_baseline_returns_em_dash() {
32828 assert_eq!(fmt_pct(100, 0), "\u{2014}");
32829 }
32830
32831 #[test]
32832 fn fmt_pct_positive_delta_has_plus_sign() {
32833 let result = fmt_pct(10, 100);
32834 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32835 }
32836
32837 #[test]
32838 fn fmt_pct_negative_delta_no_plus_sign() {
32839 let result = fmt_pct(-10, 100);
32840 assert!(!result.starts_with('+'), "unexpected + in: {result}");
32841 assert!(result.contains('%'));
32842 }
32843
32844 #[test]
32845 fn fmt_pct_near_zero_returns_pm_zero() {
32846 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32847 }
32848
32849 #[test]
32852 fn summary_delta_no_prev_returns_dash_na() {
32853 let (display, class) = summary_delta(10, None);
32854 assert_eq!(display, "\u{2014}");
32855 assert_eq!(class, "na");
32856 }
32857
32858 #[test]
32859 fn summary_delta_increase_is_positive() {
32860 let (display, class) = summary_delta(15, Some(10));
32861 assert_eq!(display, "+5");
32862 assert_eq!(class, "pos");
32863 }
32864
32865 #[test]
32866 fn summary_delta_decrease_is_negative() {
32867 let (display, class) = summary_delta(5, Some(10));
32868 assert_eq!(display, "-5");
32869 assert_eq!(class, "neg");
32870 }
32871
32872 #[test]
32875 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32876 use chrono::Datelike;
32877 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32878 assert_eq!(d.year(), 2024);
32879 assert_eq!(d.month(), 1);
32880 assert_eq!(d.weekday(), chrono::Weekday::Mon);
32881 assert!(d.day() <= 7);
32882 }
32883
32884 #[test]
32885 fn nth_weekday_second_sunday_march_2024_is_10th() {
32886 use chrono::Datelike;
32887 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32888 assert_eq!(d.weekday(), chrono::Weekday::Sun);
32889 assert_eq!(d.month(), 3);
32890 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
32891 }
32892
32893 #[test]
32896 fn is_pacific_dst_july_is_true() {
32897 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32898 assert!(is_pacific_dst(dt), "July must be PDT");
32899 }
32900
32901 #[test]
32902 fn is_pacific_dst_january_is_false() {
32903 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32904 assert!(!is_pacific_dst(dt), "January must be PST");
32905 }
32906
32907 #[test]
32908 fn fmt_la_time_summer_shows_pdt() {
32909 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32910 let result = fmt_la_time(dt);
32911 assert!(
32912 result.ends_with("PDT"),
32913 "summer must use PDT, got: {result}"
32914 );
32915 }
32916
32917 #[test]
32918 fn fmt_la_time_winter_shows_pst() {
32919 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32920 let result = fmt_la_time(dt);
32921 assert!(
32922 result.ends_with("PST"),
32923 "winter must use PST, got: {result}"
32924 );
32925 }
32926
32927 #[test]
32928 fn fmt_la_time_meta_summer_shows_pdt() {
32929 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
32930 let result = fmt_la_time_meta(dt);
32931 assert!(
32932 result.ends_with("PDT"),
32933 "meta summer must use PDT, got: {result}"
32934 );
32935 }
32936
32937 #[test]
32938 fn fmt_la_time_meta_winter_shows_pst() {
32939 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
32940 let result = fmt_la_time_meta(dt);
32941 assert!(
32942 result.ends_with("PST"),
32943 "meta winter must use PST, got: {result}"
32944 );
32945 }
32946
32947 #[test]
32950 fn fmt_git_date_valid_iso_returns_some() {
32951 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
32952 }
32953
32954 #[test]
32955 fn fmt_git_date_invalid_returns_none() {
32956 assert!(fmt_git_date("not-a-date").is_none());
32957 }
32958
32959 #[test]
32962 fn format_number_zero() {
32963 assert_eq!(format_number(0), "0");
32964 }
32965
32966 #[test]
32967 fn format_number_three_digits_no_comma() {
32968 assert_eq!(format_number(999), "999");
32969 }
32970
32971 #[test]
32972 fn format_number_four_digits_has_comma() {
32973 assert_eq!(format_number(1000), "1,000");
32974 }
32975
32976 #[test]
32977 fn format_number_seven_digits_two_commas() {
32978 assert_eq!(format_number(1_234_567), "1,234,567");
32979 }
32980
32981 #[test]
32982 fn format_number_one_million() {
32983 assert_eq!(format_number(1_000_000), "1,000,000");
32984 }
32985
32986 #[test]
32989 fn badge_text_px_empty_is_zero() {
32990 assert_eq!(badge_text_px(""), 0);
32991 }
32992
32993 #[test]
32994 fn badge_text_px_narrow_chars_smaller_than_normal() {
32995 assert!(
32996 badge_text_px("if") < badge_text_px("ab"),
32997 "'if' must be narrower than 'ab'"
32998 );
32999 }
33000
33001 #[test]
33002 fn badge_text_px_m_is_wider_than_a() {
33003 assert!(
33004 badge_text_px("m") > badge_text_px("a"),
33005 "'m' must be wider than 'a'"
33006 );
33007 }
33008
33009 #[test]
33010 fn render_badge_svg_contains_label_and_value() {
33011 let svg = render_badge_svg("coverage", "95%", "#4c1");
33012 assert!(svg.contains("coverage") && svg.contains("95%"));
33013 }
33014
33015 #[test]
33016 fn render_badge_svg_contains_color() {
33017 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33018 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33019 }
33020
33021 #[test]
33022 fn render_badge_svg_escapes_ampersand_in_label() {
33023 let svg = render_badge_svg("test&label", "ok", "#4c1");
33024 assert!(svg.contains("&") && !svg.contains("test&label"));
33025 }
33026
33027 #[test]
33030 fn build_pdf_filename_slugifies_title() {
33031 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33032 assert!(
33033 name.starts_with("my_project_report_")
33034 && std::path::Path::new(&name)
33035 .extension()
33036 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33037 );
33038 }
33039
33040 #[test]
33041 fn build_pdf_filename_uses_last_run_id_segment() {
33042 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33043 assert!(name.contains("ABCD"), "must use last segment of run_id");
33044 }
33045
33046 #[test]
33047 fn build_pdf_filename_empty_title_uses_report_prefix() {
33048 let name = build_pdf_filename("", "abc-def-9999");
33049 assert!(
33050 name.starts_with("report_")
33051 && std::path::Path::new(&name)
33052 .extension()
33053 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33054 );
33055 }
33056
33057 #[test]
33060 fn swap_chart_js_replaces_inline_block() {
33061 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33062 let result = swap_inline_chart_js_for_static(html.to_string());
33063 assert!(result.contains(r#"src="/static/chart-report.js""#));
33064 assert!(!result.contains("inline source"));
33065 }
33066
33067 #[test]
33068 fn swap_chart_js_no_head_returns_unchanged() {
33069 let html = "<body>no head here</body>";
33070 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33071 }
33072
33073 #[test]
33074 fn swap_chart_js_no_script_in_head_unchanged() {
33075 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33076 let result = swap_inline_chart_js_for_static(html.to_string());
33077 assert!(!result.contains("chart-report.js"));
33078 }
33079
33080 #[test]
33083 fn patch_html_nonce_replaces_old_nonce() {
33084 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33085 let result = patch_html_nonce(html, "new-nonce-456");
33086 assert!(result.contains(r#"nonce="new-nonce-456""#));
33087 assert!(!result.contains("old-nonce-123"));
33088 }
33089
33090 #[test]
33091 fn patch_html_nonce_injects_into_bare_style() {
33092 let html = "<style>body{color:red;}</style>";
33093 let result = patch_html_nonce(html, "fresh-nonce");
33094 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33095 }
33096
33097 #[test]
33098 fn patch_html_nonce_injects_into_bare_script() {
33099 let html = "<script>console.log(1);</script>";
33100 let result = patch_html_nonce(html, "abc");
33101 assert!(result.contains(r#"<script nonce="abc">"#));
33102 }
33103
33104 #[test]
33107 fn is_html_report_file_result_html_matches() {
33108 let dir = tempfile::tempdir().unwrap();
33109 let path = dir.path().join("result_20240101.html");
33110 std::fs::write(&path, b"<html></html>").unwrap();
33111 assert!(is_html_report_file(&path));
33112 }
33113
33114 #[test]
33115 fn is_html_report_file_report_html_matches() {
33116 let dir = tempfile::tempdir().unwrap();
33117 let path = dir.path().join("report_abc.html");
33118 std::fs::write(&path, b"<html></html>").unwrap();
33119 assert!(is_html_report_file(&path));
33120 }
33121
33122 #[test]
33123 fn is_html_report_file_index_html_does_not_match() {
33124 let dir = tempfile::tempdir().unwrap();
33125 let path = dir.path().join("index.html");
33126 std::fs::write(&path, b"<html></html>").unwrap();
33127 assert!(!is_html_report_file(&path));
33128 }
33129
33130 #[test]
33131 fn is_html_report_file_nonexistent_returns_false() {
33132 assert!(!is_html_report_file(Path::new(
33133 "/nonexistent/result_xyz.html"
33134 )));
33135 }
33136
33137 #[test]
33138 fn find_html_report_in_dir_finds_result_html() {
33139 let dir = tempfile::tempdir().unwrap();
33140 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33141 assert!(find_html_report_in_dir(dir.path()).is_some());
33142 }
33143
33144 #[test]
33145 fn find_html_report_in_dir_empty_returns_none() {
33146 let dir = tempfile::tempdir().unwrap();
33147 assert!(find_html_report_in_dir(dir.path()).is_none());
33148 }
33149
33150 #[test]
33151 fn find_html_report_in_tree_finds_in_subdir() {
33152 let dir = tempfile::tempdir().unwrap();
33153 let subdir = dir.path().join("run-001");
33154 std::fs::create_dir_all(&subdir).unwrap();
33155 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33156 assert!(find_html_report_in_tree(dir.path()).is_some());
33157 }
33158
33159 #[test]
33162 fn derive_project_label_with_git_repo_and_ref() {
33163 let label = derive_project_label(
33164 Some("https://github.com/owner/my-repo.git"),
33165 Some("main"),
33166 "/fallback/path",
33167 );
33168 assert!(!label.is_empty(), "label must not be empty");
33169 assert!(
33170 label.contains("my") || label.contains("repo"),
33171 "got: {label}"
33172 );
33173 }
33174
33175 #[test]
33176 fn derive_project_label_fallback_to_path() {
33177 let label = derive_project_label(None, None, "/path/to/myproject");
33178 assert_eq!(label, "myproject");
33179 }
33180
33181 #[test]
33182 fn derive_project_label_empty_git_fields_use_path() {
33183 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33184 assert_eq!(label, "cool-app");
33185 }
33186
33187 #[test]
33190 fn derive_file_stem_with_commit_appends_sha() {
33191 assert_eq!(
33192 derive_file_stem("myproject", Some("a1b2c3")),
33193 "myproject_a1b2c3"
33194 );
33195 }
33196
33197 #[test]
33198 fn derive_file_stem_without_commit_returns_label() {
33199 assert_eq!(derive_file_stem("myproject", None), "myproject");
33200 }
33201
33202 #[test]
33203 fn derive_file_stem_empty_commit_returns_label() {
33204 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33205 }
33206
33207 #[test]
33210 fn split_patterns_none_is_empty() {
33211 assert!(split_patterns(None).is_empty());
33212 }
33213
33214 #[test]
33215 fn split_patterns_empty_string_is_empty() {
33216 assert!(split_patterns(Some("")).is_empty());
33217 }
33218
33219 #[test]
33220 fn split_patterns_comma_separated() {
33221 assert_eq!(
33222 split_patterns(Some("foo,bar,baz")),
33223 vec!["foo", "bar", "baz"]
33224 );
33225 }
33226
33227 #[test]
33228 fn split_patterns_newline_separated() {
33229 assert_eq!(
33230 split_patterns(Some("foo\nbar\nbaz")),
33231 vec!["foo", "bar", "baz"]
33232 );
33233 }
33234
33235 #[test]
33236 fn split_patterns_trims_whitespace() {
33237 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
33238 }
33239
33240 #[test]
33243 fn make_git_label_empty_repo_empty_result() {
33244 assert_eq!(make_git_label("", "main"), "");
33245 }
33246
33247 #[test]
33248 fn make_git_label_empty_ref_empty_result() {
33249 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33250 }
33251
33252 #[test]
33253 fn make_git_label_basic_format() {
33254 assert_eq!(
33255 make_git_label("https://github.com/owner/my-repo.git", "main"),
33256 "my-repo_at_main_sloc"
33257 );
33258 }
33259
33260 #[test]
33261 fn make_git_label_slash_in_ref_replaced() {
33262 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33263 assert!(
33264 !label.contains('/'),
33265 "slash in ref must be replaced: {label}"
33266 );
33267 }
33268
33269 #[test]
33272 fn format_dir_size_bytes() {
33273 assert_eq!(format_dir_size(500), "500 B");
33274 }
33275
33276 #[test]
33277 fn format_dir_size_kilobytes() {
33278 assert_eq!(format_dir_size(2048), "2 KB");
33279 }
33280
33281 #[test]
33282 fn format_dir_size_megabytes() {
33283 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33284 }
33285
33286 #[test]
33287 fn format_dir_size_gigabytes() {
33288 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33289 }
33290
33291 #[test]
33292 fn format_dir_size_zero() {
33293 assert_eq!(format_dir_size(0), "0 B");
33294 }
33295
33296 #[test]
33299 fn civil_from_days_epoch() {
33300 assert_eq!(civil_from_days(0), (1970, 1, 1));
33301 }
33302
33303 #[test]
33304 fn civil_from_days_one_year_later() {
33305 assert_eq!(civil_from_days(365), (1971, 1, 1));
33306 }
33307
33308 #[test]
33309 fn civil_from_days_31_days_is_feb_1_1970() {
33310 assert_eq!(civil_from_days(31), (1970, 2, 1));
33311 }
33312
33313 #[test]
33316 fn format_system_time_unix_epoch_formats_correctly() {
33317 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33318 }
33319
33320 #[test]
33321 fn format_system_time_31_days_after_epoch() {
33322 let t = UNIX_EPOCH + Duration::from_hours(744);
33323 assert_eq!(format_system_time(t), "1970-02-01 00:00");
33324 }
33325
33326 #[test]
33327 fn format_system_time_before_epoch_returns_dash() {
33328 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33329 assert_eq!(format_system_time(before), "-");
33330 }
33331 }
33332
33333 #[test]
33336 fn detect_language_name_dot_c() {
33337 assert_eq!(detect_language_name("main.c"), Some("C"));
33338 }
33339
33340 #[test]
33341 fn detect_language_name_dot_h() {
33342 assert_eq!(detect_language_name("defs.h"), Some("C"));
33343 }
33344
33345 #[test]
33346 fn detect_language_name_dot_cpp() {
33347 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33348 }
33349
33350 #[test]
33351 fn detect_language_name_dot_py() {
33352 assert_eq!(detect_language_name("script.py"), Some("Python"));
33353 }
33354
33355 #[test]
33356 fn detect_language_name_dot_ps1() {
33357 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33358 }
33359
33360 #[test]
33361 fn detect_language_name_dot_cs() {
33362 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33363 }
33364
33365 #[test]
33366 fn detect_language_name_dot_sh() {
33367 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33368 }
33369
33370 #[test]
33371 fn detect_language_name_unknown_txt() {
33372 assert_eq!(detect_language_name("notes.txt"), None);
33373 }
33374
33375 #[test]
33378 fn language_icon_file_c() {
33379 assert_eq!(language_icon_file("C"), Some("c.png"));
33380 }
33381
33382 #[test]
33383 fn language_icon_file_python() {
33384 assert_eq!(language_icon_file("Python"), Some("python.png"));
33385 }
33386
33387 #[test]
33388 fn language_icon_file_dockerfile() {
33389 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33390 }
33391
33392 #[test]
33393 fn language_icon_file_rust_is_none() {
33394 assert!(language_icon_file("Rust").is_none());
33395 }
33396
33397 #[test]
33398 fn language_icon_file_unknown_is_none() {
33399 assert!(language_icon_file("Fortran").is_none());
33400 }
33401
33402 #[test]
33405 fn language_inline_svg_rust_is_svg() {
33406 let svg = language_inline_svg("Rust").unwrap();
33407 assert!(svg.starts_with("<svg"));
33408 }
33409
33410 #[test]
33411 fn language_inline_svg_typescript_is_some() {
33412 assert!(language_inline_svg("TypeScript").is_some());
33413 }
33414
33415 #[test]
33416 fn language_inline_svg_unknown_is_none() {
33417 assert!(language_inline_svg("Fortran").is_none());
33418 }
33419
33420 #[test]
33423 fn classify_preview_file_c_supported() {
33424 assert!(matches!(
33425 classify_preview_file("main.c"),
33426 PreviewKind::Supported
33427 ));
33428 }
33429
33430 #[test]
33431 fn classify_preview_file_python_supported() {
33432 assert!(matches!(
33433 classify_preview_file("script.py"),
33434 PreviewKind::Supported
33435 ));
33436 }
33437
33438 #[test]
33439 fn classify_preview_file_png_skipped() {
33440 assert!(matches!(
33441 classify_preview_file("image.png"),
33442 PreviewKind::Skipped
33443 ));
33444 }
33445
33446 #[test]
33447 fn classify_preview_file_zip_skipped() {
33448 assert!(matches!(
33449 classify_preview_file("archive.zip"),
33450 PreviewKind::Skipped
33451 ));
33452 }
33453
33454 #[test]
33455 fn classify_preview_file_min_js_skipped() {
33456 assert!(matches!(
33457 classify_preview_file("bundle.min.js"),
33458 PreviewKind::Skipped
33459 ));
33460 }
33461
33462 #[test]
33463 fn classify_preview_file_rs_unsupported() {
33464 assert!(matches!(
33465 classify_preview_file("main.rs"),
33466 PreviewKind::Unsupported
33467 ));
33468 }
33469
33470 #[test]
33473 fn preview_relative_path_strips_root() {
33474 let root = PathBuf::from("/project");
33475 let path = PathBuf::from("/project/src/main.c");
33476 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33477 }
33478
33479 #[test]
33480 fn preview_relative_path_unrooted_includes_filename() {
33481 let root = PathBuf::from("/other");
33482 let path = PathBuf::from("/project/src/main.c");
33483 let result = preview_relative_path(&root, &path);
33484 assert!(result.contains("main.c"));
33485 }
33486
33487 #[test]
33488 fn preview_relative_path_uses_forward_slashes() {
33489 let root = PathBuf::from("/project");
33490 let path = PathBuf::from("/project/a/b/c.py");
33491 assert!(!preview_relative_path(&root, &path).contains('\\'));
33492 }
33493
33494 #[test]
33497 fn wildcard_match_exact_equal() {
33498 assert!(wildcard_match("foo", "foo"));
33499 }
33500
33501 #[test]
33502 fn wildcard_match_exact_mismatch() {
33503 assert!(!wildcard_match("foo", "bar"));
33504 }
33505
33506 #[test]
33507 fn wildcard_match_star_suffix() {
33508 assert!(wildcard_match("*.rs", "main.rs"));
33509 }
33510
33511 #[test]
33512 fn wildcard_match_star_middle_requires_suffix() {
33513 assert!(!wildcard_match("a*b", "ac"));
33514 }
33515
33516 #[test]
33517 fn wildcard_match_question_mark_single_char() {
33518 assert!(wildcard_match("f?o", "foo"));
33519 }
33520
33521 #[test]
33522 fn wildcard_match_double_star_nested() {
33523 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33524 }
33525
33526 #[test]
33527 fn wildcard_match_star_directory_entry() {
33528 assert!(wildcard_match("vendor/*", "vendor/crate"));
33529 }
33530
33531 #[test]
33532 fn wildcard_match_no_cross_prefix() {
33533 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33534 }
33535
33536 #[test]
33539 fn should_skip_empty_relative_is_false() {
33540 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33541 }
33542
33543 #[test]
33544 fn should_skip_matching_pattern() {
33545 assert!(should_skip_preview_directory(
33546 "vendor",
33547 &["vendor".to_string()]
33548 ));
33549 }
33550
33551 #[test]
33552 fn should_skip_non_matching() {
33553 assert!(!should_skip_preview_directory(
33554 "src",
33555 &["vendor".to_string()]
33556 ));
33557 }
33558
33559 #[test]
33560 fn should_skip_wildcard_prefix() {
33561 assert!(should_skip_preview_directory(
33562 "target/debug",
33563 &["target*".to_string()]
33564 ));
33565 }
33566
33567 #[test]
33570 fn should_include_empty_relative_always_true() {
33571 assert!(should_include_preview_file("", &[], &[]));
33572 }
33573
33574 #[test]
33575 fn should_include_no_patterns_includes_all() {
33576 assert!(should_include_preview_file("src/main.c", &[], &[]));
33577 }
33578
33579 #[test]
33580 fn should_include_excluded_by_pattern() {
33581 assert!(!should_include_preview_file(
33582 "vendor/lib.c",
33583 &[],
33584 &["vendor/*".to_string()]
33585 ));
33586 }
33587
33588 #[test]
33589 fn should_include_include_pattern_filters() {
33590 assert!(!should_include_preview_file(
33591 "tests/test_foo.c",
33592 &["src/*".to_string()],
33593 &[]
33594 ));
33595 }
33596
33597 #[test]
33600 fn escape_html_ampersand() {
33601 assert_eq!(escape_html("a&b"), "a&b");
33602 }
33603
33604 #[test]
33605 fn escape_html_angle_brackets() {
33606 assert_eq!(escape_html("<br>"), "<br>");
33607 }
33608
33609 #[test]
33610 fn escape_html_double_quote() {
33611 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
33612 }
33613
33614 #[test]
33615 fn escape_html_single_quote() {
33616 assert_eq!(escape_html("it's"), "it's");
33617 }
33618
33619 #[test]
33620 fn escape_html_plain_text_unchanged() {
33621 assert_eq!(escape_html("hello world"), "hello world");
33622 }
33623
33624 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33627 sloc_core::ScanComparison {
33628 summary: sloc_core::SummaryDelta {
33629 baseline_run_id: "base".to_string(),
33630 current_run_id: "curr".to_string(),
33631 baseline_timestamp: chrono::Utc::now(),
33632 current_timestamp: chrono::Utc::now(),
33633 baseline_files: 4,
33634 current_files: 4,
33635 files_analyzed_delta: 0,
33636 baseline_code: 330,
33637 current_code: 400,
33638 code_lines_delta: 70,
33639 baseline_comments: 0,
33640 current_comments: 0,
33641 comment_lines_delta: 0,
33642 blank_lines_delta: 0,
33643 total_lines_delta: 70,
33644 coverage_lines_hit_delta: None,
33645 coverage_line_pct_delta: None,
33646 baseline_coverage_line_pct: None,
33647 current_coverage_line_pct: None,
33648 },
33649 file_deltas: vec![
33650 sloc_core::FileDelta {
33651 relative_path: "added.rs".to_string(),
33652 language: Some("Rust".to_string()),
33653 status: FileChangeStatus::Added,
33654 baseline_code: 0,
33655 current_code: 100,
33656 code_delta: 100,
33657 baseline_comment: 0,
33658 current_comment: 0,
33659 comment_delta: 0,
33660 baseline_blank: 0,
33661 current_blank: 0,
33662 blank_delta: 0,
33663 total_delta: 100,
33664 },
33665 sloc_core::FileDelta {
33666 relative_path: "removed.rs".to_string(),
33667 language: Some("Rust".to_string()),
33668 status: FileChangeStatus::Removed,
33669 baseline_code: 50,
33670 current_code: 0,
33671 code_delta: -50,
33672 baseline_comment: 0,
33673 current_comment: 0,
33674 comment_delta: 0,
33675 baseline_blank: 0,
33676 current_blank: 0,
33677 blank_delta: 0,
33678 total_delta: -50,
33679 },
33680 sloc_core::FileDelta {
33681 relative_path: "modified.rs".to_string(),
33682 language: Some("Rust".to_string()),
33683 status: FileChangeStatus::Modified,
33684 baseline_code: 80,
33685 current_code: 100,
33686 code_delta: 20,
33687 baseline_comment: 0,
33688 current_comment: 0,
33689 comment_delta: 0,
33690 baseline_blank: 0,
33691 current_blank: 0,
33692 blank_delta: 0,
33693 total_delta: 20,
33694 },
33695 sloc_core::FileDelta {
33696 relative_path: "unchanged.rs".to_string(),
33697 language: Some("Rust".to_string()),
33698 status: FileChangeStatus::Unchanged,
33699 baseline_code: 200,
33700 current_code: 200,
33701 code_delta: 0,
33702 baseline_comment: 0,
33703 current_comment: 0,
33704 comment_delta: 0,
33705 baseline_blank: 0,
33706 current_blank: 0,
33707 blank_delta: 0,
33708 total_delta: 0,
33709 },
33710 ],
33711 files_added: 1,
33712 files_removed: 1,
33713 files_modified: 1,
33714 files_unchanged: 1,
33715 }
33716 }
33717
33718 #[test]
33719 fn sum_added_counts_added_and_positive_modified() {
33720 let cmp = make_mixed_scan_comparison();
33721 assert_eq!(sum_added_code_lines(&cmp), 120);
33722 }
33723
33724 #[test]
33725 fn sum_removed_counts_removed_baseline() {
33726 let cmp = make_mixed_scan_comparison();
33727 assert_eq!(sum_removed_code_lines(&cmp), 50);
33728 }
33729
33730 #[test]
33731 fn sum_unmodified_counts_unchanged_files() {
33732 let cmp = make_mixed_scan_comparison();
33733 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33734 }
33735
33736 #[test]
33739 fn detect_coverage_tool_rust_project() {
33740 let dir = tempfile::tempdir().unwrap();
33741 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33742 let (tool, cmd) = detect_coverage_tool(dir.path());
33743 assert_eq!(tool, Some("cargo-llvm-cov"));
33744 assert!(cmd.is_some());
33745 }
33746
33747 #[test]
33748 fn detect_coverage_tool_java_gradle() {
33749 let dir = tempfile::tempdir().unwrap();
33750 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33751 let (tool, _) = detect_coverage_tool(dir.path());
33752 assert_eq!(tool, Some("jacoco"));
33753 }
33754
33755 #[test]
33756 fn detect_coverage_tool_python_pyproject() {
33757 let dir = tempfile::tempdir().unwrap();
33758 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33759 let (tool, _) = detect_coverage_tool(dir.path());
33760 assert_eq!(tool, Some("pytest-cov"));
33761 }
33762
33763 #[test]
33764 fn detect_coverage_tool_unknown_project() {
33765 let dir = tempfile::tempdir().unwrap();
33766 let (tool, cmd) = detect_coverage_tool(dir.path());
33767 assert!(tool.is_none() && cmd.is_none());
33768 }
33769
33770 #[test]
33773 fn sanitize_path_str_unc_drive_stripped() {
33774 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33775 }
33776
33777 #[test]
33778 fn sanitize_path_str_unc_network_stripped() {
33779 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33780 }
33781
33782 #[test]
33783 fn sanitize_path_str_plain_path_unchanged() {
33784 assert_eq!(
33785 sanitize_path_str("/home/user/project"),
33786 "/home/user/project"
33787 );
33788 }
33789
33790 #[test]
33791 fn display_path_plain_linux_unchanged() {
33792 assert_eq!(
33793 display_path(Path::new("/home/user/project")),
33794 "/home/user/project"
33795 );
33796 }
33797
33798 #[test]
33799 fn display_path_unc_drive_stripped() {
33800 let result = display_path(Path::new(r"\\?\C:\Users\user"));
33801 assert_eq!(result, r"C:\Users\user");
33802 }
33803
33804 #[test]
33805 fn display_path_unc_network_stripped() {
33806 let result = display_path(Path::new(r"\\?\UNC\server\share"));
33807 assert_eq!(result, r"\\server\share");
33808 }
33809}
33810
33811#[cfg(test)]
33812mod coverage_boost_unit_tests {
33813 use super::*;
33814 use std::path::{Path, PathBuf};
33815
33816 #[tokio::test]
33820 async fn runtime_security_config_scenarios() {
33821 std::env::remove_var("SLOC_API_KEYS");
33822 std::env::remove_var("SLOC_API_KEY");
33823 std::env::remove_var("SLOC_TLS_CERT");
33824 std::env::remove_var("SLOC_TLS_KEY");
33825 std::env::remove_var("SLOC_TRUST_PROXY");
33826 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33827 let cfg = load_runtime_security_config(false);
33828 assert!(cfg.api_keys.is_empty());
33829 assert!(!cfg.tls_enabled);
33830 assert!(!cfg.trust_proxy);
33831
33832 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33833 std::env::set_var("SLOC_TRUST_PROXY", "1");
33834 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33835 std::env::set_var("SLOC_RATE_LIMIT", "250");
33836 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33837 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33838 let cfg = load_runtime_security_config(true);
33839 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33840 assert!(cfg.trust_proxy);
33841 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33842 std::env::remove_var("SLOC_API_KEYS");
33843 std::env::remove_var("SLOC_TRUST_PROXY");
33844 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33845 std::env::remove_var("SLOC_RATE_LIMIT");
33846 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33847 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33848 }
33849
33850 #[test]
33851 fn cors_layer_builds_both_modes() {
33852 let _ = build_cors_layer(true);
33853 let _ = build_cors_layer(false);
33854 }
33855
33856 #[test]
33857 fn primary_lan_ip_callable() {
33858 let _ = primary_lan_ip();
33860 }
33861
33862 #[test]
33863 fn safe_redirect_allows_relative_rejects_absolute() {
33864 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33865 assert_eq!(safe_redirect("https://evil.example/x"), "/");
33866 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33867 assert_eq!(default_redirect(), "/view-reports");
33868 }
33869
33870 #[test]
33871 fn tarball_size_caps_env_override() {
33872 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33873 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33874 let (c, d) = parse_tarball_size_caps();
33875 assert_eq!(c, 1024 * 1024);
33876 assert_eq!(d, 2 * 1024 * 1024);
33877 std::env::remove_var("SLOC_MAX_TARBALL_MB");
33878 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33879 let (c2, _) = parse_tarball_size_caps();
33880 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33881 }
33882
33883 #[test]
33884 fn upload_path_helpers() {
33885 let base = upload_base_dir();
33886 let staged = upload_staging_path("abc123");
33887 assert!(staged.starts_with(&base));
33888 assert!(
33889 is_upload_tmp_path(&staged),
33890 "staging path is an upload tmp path"
33891 );
33892 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
33893 }
33894
33895 #[test]
33896 fn git_clones_dir_env_override() {
33897 std::env::remove_var("SLOC_GIT_CLONES_DIR");
33898 let def = resolve_git_clones_dir(Path::new("/out"));
33899 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
33900 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
33901 assert_eq!(
33902 resolve_git_clones_dir(Path::new("/out")),
33903 PathBuf::from("/custom/clones")
33904 );
33905 std::env::remove_var("SLOC_GIT_CLONES_DIR");
33906 }
33907
33908 #[test]
33909 fn html_report_file_detection() {
33910 let dir = std::env::temp_dir().join("sloc_html_detect");
33911 let _ = std::fs::create_dir_all(&dir);
33912 let good = dir.join("report_x.html");
33913 std::fs::write(&good, "<html></html>").unwrap();
33914 let bad = dir.join("notes.txt");
33915 std::fs::write(&bad, "x").unwrap();
33916 assert!(is_html_report_file(&good));
33917 assert!(!is_html_report_file(&bad));
33918 assert!(find_html_report_in_dir(&dir).is_some());
33919 let _ = std::fs::remove_dir_all(&dir);
33920 }
33921
33922 #[test]
33923 fn multi_delta_class_and_format() {
33924 assert_eq!(multi_delta_class(5), "pos");
33925 assert_eq!(multi_delta_class(-5), "neg");
33926 assert_eq!(multi_delta_class(0), "zero");
33927 assert_eq!(multi_fmt_delta(3), "+3");
33928 assert_eq!(multi_fmt_delta(-3), "-3");
33929 assert_eq!(multi_fmt_delta(0), "0");
33930 }
33931
33932 #[test]
33933 fn git_clone_dest_sanitizes() {
33934 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
33935 assert!(dest.starts_with("/clones"));
33936 let name = dest.file_name().unwrap().to_str().unwrap();
33937 assert!(name
33938 .chars()
33939 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
33940 }
33941}
33942
33943#[cfg(test)]
33944mod tests_private {
33945 use super::*;
33946 use std::io::Read;
33947
33948 #[test]
33949 fn size_limit_reader_zero_remaining_returns_error() {
33950 let data = b"hello world";
33951 let mut reader = SizeLimitReader {
33952 inner: &data[..],
33953 remaining: 0,
33954 };
33955 let mut buf = [0u8; 4];
33956 assert!(reader.read(&mut buf).is_err());
33957 }
33958
33959 #[test]
33960 fn size_limit_reader_counts_bytes() {
33961 let data = b"hello world";
33962 let mut reader = SizeLimitReader {
33963 inner: &data[..],
33964 remaining: 5,
33965 };
33966 let mut buf = [0u8; 4];
33967 let n = reader.read(&mut buf).unwrap();
33968 assert_eq!(n, 4);
33969 assert_eq!(reader.remaining, 1);
33970 }
33971
33972 #[test]
33973 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
33974 let uuid = "12345678-1234-1234-1234-123456789012";
33975 let (id, path) = resolve_or_create_staging(Some(uuid));
33976 assert_eq!(id, uuid);
33977 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
33978 }
33979
33980 #[test]
33981 fn resolve_or_create_staging_with_none_creates_new() {
33982 let (id1, _) = resolve_or_create_staging(None);
33983 let (id2, _) = resolve_or_create_staging(None);
33984 assert_ne!(id1, id2);
33985 }
33986
33987 #[test]
33988 fn resolve_or_create_staging_with_path_separator_creates_new() {
33989 let (id, _) = resolve_or_create_staging(Some("has/slash"));
33991 assert_ne!(id, "has/slash");
33992 }
33993
33994 #[test]
33995 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
33996 use std::net::IpAddr;
33997 use std::str::FromStr;
33998 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
33999 let ip = IpAddr::from_str("192.168.1.1").unwrap();
34000 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34001 }
34002
34003 #[test]
34004 fn is_auth_locked_out_expired_entry_removed() {
34005 use std::net::IpAddr;
34006 use std::str::FromStr;
34007 let limiter = IpRateLimiter::new(
34008 Duration::from_mins(1),
34009 100,
34010 1, Duration::from_millis(1),
34012 );
34013 let ip = IpAddr::from_str("192.168.1.2").unwrap();
34014 limiter.record_auth_failure(ip);
34015 std::thread::sleep(Duration::from_millis(10));
34017 assert!(!limiter.is_auth_locked_out(ip));
34019 }
34020
34021 #[test]
34022 fn is_auth_locked_out_within_window_returns_true() {
34023 use std::net::IpAddr;
34024 use std::str::FromStr;
34025 let limiter = IpRateLimiter::new(
34026 Duration::from_mins(1),
34027 100,
34028 2, Duration::from_hours(1),
34030 );
34031 let ip = IpAddr::from_str("192.168.1.3").unwrap();
34032 limiter.record_auth_failure(ip);
34033 limiter.record_auth_failure(ip);
34034 assert!(limiter.is_auth_locked_out(ip));
34035 }
34036
34037 #[test]
34040 fn output_folder_hint_strips_json_subdir() {
34041 use std::path::Path;
34042 let path = Path::new("/output/scan1/json/result.json");
34043 let hint = output_folder_hint(path);
34044 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34045 }
34046
34047 #[test]
34048 fn output_folder_hint_strips_html_subdir() {
34049 use std::path::Path;
34050 let path = Path::new("/output/scan1/html/report.html");
34051 let hint = output_folder_hint(path);
34052 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34053 }
34054
34055 #[test]
34056 fn output_folder_hint_strips_pdf_subdir() {
34057 use std::path::Path;
34058 let path = Path::new("/output/scan1/pdf/report.pdf");
34059 let hint = output_folder_hint(path);
34060 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34061 }
34062
34063 #[test]
34064 fn output_folder_hint_strips_excel_subdir() {
34065 use std::path::Path;
34066 let path = Path::new("/output/scan1/excel/report.xlsx");
34067 let hint = output_folder_hint(path);
34068 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34069 }
34070
34071 #[test]
34072 fn output_folder_hint_flat_layout_returns_direct_parent() {
34073 use std::path::Path;
34074 let path = Path::new("/output/scan1/result.json");
34075 let hint = output_folder_hint(path);
34076 assert!(
34077 hint.ends_with("scan1"),
34078 "expected direct parent, got: {hint}"
34079 );
34080 }
34081
34082 #[test]
34083 fn output_folder_hint_other_subdir_name_not_stripped() {
34084 use std::path::Path;
34085 let path = Path::new("/output/scan1/data/result.json");
34087 let hint = output_folder_hint(path);
34088 assert!(
34089 hint.ends_with("data"),
34090 "non-artifact subdir must not be stripped, got: {hint}"
34091 );
34092 }
34093
34094 #[test]
34097 fn find_file_by_ext_finds_matching_file() {
34098 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34099 let _ = fs::create_dir_all(&dir);
34100 let f = dir.join("report.pdf");
34101 let _ = fs::write(&f, b"dummy");
34102 let result = find_file_by_ext(&dir, "pdf");
34103 assert!(result.is_some(), "expected to find report.pdf");
34104 let _ = fs::remove_dir_all(&dir);
34105 }
34106
34107 #[test]
34108 fn find_file_by_ext_returns_none_for_missing_ext() {
34109 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34110 let _ = fs::create_dir_all(&dir);
34111 let f = dir.join("report.json");
34112 let _ = fs::write(&f, b"{}");
34113 let result = find_file_by_ext(&dir, "pdf");
34114 assert!(result.is_none());
34115 let _ = fs::remove_dir_all(&dir);
34116 }
34117
34118 #[test]
34119 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34120 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34121 assert!(find_file_by_ext(dir, "json").is_none());
34122 }
34123
34124 #[test]
34127 fn collect_result_json_candidates_flat_root() {
34128 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34129 let _ = fs::create_dir_all(&root);
34130 let _ = fs::write(root.join("result.json"), b"{}");
34131 let candidates = collect_result_json_candidates(&root);
34132 assert!(!candidates.is_empty(), "should find result.json at root");
34133 let _ = fs::remove_dir_all(&root);
34134 }
34135
34136 #[test]
34137 fn collect_result_json_candidates_legacy_subdir() {
34138 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34139 let sub = root.join("scanA");
34140 let _ = fs::create_dir_all(&sub);
34141 let _ = fs::write(sub.join("result.json"), b"{}");
34142 let candidates = collect_result_json_candidates(&root);
34143 assert!(
34144 !candidates.is_empty(),
34145 "should find result.json in legacy subdir"
34146 );
34147 let _ = fs::remove_dir_all(&root);
34148 }
34149
34150 #[test]
34151 fn collect_result_json_candidates_structured_json_subdir() {
34152 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34153 let json_sub = root.join("scanB").join("json");
34154 let _ = fs::create_dir_all(&json_sub);
34155 let _ = fs::write(json_sub.join("result.json"), b"{}");
34156 let candidates = collect_result_json_candidates(&root);
34157 assert!(
34158 !candidates.is_empty(),
34159 "should find result.json inside <subdir>/json/"
34160 );
34161 let _ = fs::remove_dir_all(&root);
34162 }
34163
34164 #[test]
34165 fn collect_result_json_candidates_empty_dir() {
34166 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34167 let _ = fs::create_dir_all(&root);
34168 let candidates = collect_result_json_candidates(&root);
34169 assert!(candidates.is_empty());
34170 let _ = fs::remove_dir_all(&root);
34171 }
34172}