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).layer(DefaultBodyLimit::disable()),
657 )
658 .route("/locate-report", post(locate_report_handler))
659 .route("/locate-reports-dir", post(locate_reports_dir_handler))
660 .route("/relocate-scan", post(relocate_scan_handler))
661 .route("/watched-dirs/add", post(add_watched_dir_handler))
662 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
663 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
664 .route("/view-reports", get(history_handler))
665 .route("/compare-scans", get(compare_select_handler))
666 .route("/compare", get(compare_handler))
667 .route("/multi-compare", get(multi_compare_handler))
668 .route("/images/{folder}/{file}", get(image_handler))
669 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
670 .route("/api/metrics/latest", get(api_metrics_latest_handler))
671 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
672 .route("/api/metrics/history", get(api_metrics_history_handler))
673 .route(
674 "/api/metrics/submodules",
675 get(api_metrics_submodules_handler),
676 )
677 .route("/api/ingest", post(api_ingest_handler))
678 .route("/api/project-history", get(project_history_handler))
679 .route("/trend-reports", get(trend_report_handler))
680 .route("/test-metrics", get(test_metrics_handler))
681 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
682 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
683 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
684 .route("/runs/result/{run_id}", get(async_run_result_handler))
685 .route("/embed/summary", get(embed_handler))
686 .route("/git-browser", get(git_browser::git_browser_handler))
688 .route("/api/git/refs", get(git_browser::api_list_refs))
689 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
690 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
691 .route("/export/pdf", post(export_pdf_handler))
693 .route("/export-config", get(export_config_handler))
695 .route("/import-config", post(import_config_handler))
696 .route("/api/scan-profiles", get(api_list_scan_profiles))
698 .route("/api/scan-profiles", post(api_save_scan_profile))
699 .route(
700 "/api/scan-profiles/{id}",
701 axum::routing::delete(api_delete_scan_profile),
702 )
703 .route("/integrations", get(integrations::integrations_handler))
705 .route(
706 "/webhook-setup",
707 get(|| async { axum::response::Redirect::permanent("/integrations") }),
708 )
709 .route(
710 "/confluence-setup",
711 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
712 )
713 .route("/api/schedules", get(git_webhook::api_list_schedules))
714 .route("/api/schedules", post(git_webhook::api_create_schedule))
715 .route(
716 "/api/schedules",
717 axum::routing::delete(git_webhook::api_delete_schedule),
718 )
719 .route(
720 "/api/confluence/config",
721 get(confluence::api_get_confluence_config),
722 )
723 .route(
724 "/api/confluence/config",
725 post(confluence::api_save_confluence_config),
726 )
727 .route(
728 "/api/confluence/test",
729 post(confluence::api_test_confluence),
730 )
731 .route(
732 "/api/confluence/post",
733 post(confluence::api_post_to_confluence),
734 )
735 .route(
736 "/api/confluence/wiki-markup",
737 get(confluence::api_wiki_markup),
738 )
739 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
741 .route(
742 "/api/runs/{run_id}",
743 axum::routing::delete(delete_run_handler),
744 )
745 .route("/api/runs/cleanup", post(cleanup_runs_handler))
746 .route(
748 "/api/cleanup-policy",
749 get(api_get_cleanup_policy)
750 .post(api_save_cleanup_policy)
751 .delete(api_delete_cleanup_policy),
752 )
753 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
754 .route("/api-docs", get(api_docs_handler))
756 .route("/metrics", get(metrics_handler))
758 .route_layer(middleware::from_fn_with_state(
759 state.clone(),
760 auth::require_api_key,
761 ));
762
763 protected
764 .route("/healthz", get(healthz))
765 .route("/api/health", get(healthz))
766 .route("/api/version", get(api_version_handler))
767 .route("/api/openapi.yaml", get(openapi_yaml_handler))
768 .route("/llms.txt", get(llms_txt_handler))
769 .route("/llms-full.txt", get(llms_full_txt_handler))
770 .route("/badge/{metric}", get(badge_handler))
771 .route("/static/chart.js", get(chart_js_handler))
772 .route("/static/chart-report.js", get(report_chart_js_handler))
773 .route("/auth/login", get(auth::auth_login_get))
774 .route("/auth/login", post(auth::auth_login_post))
775 .route(
778 "/webhooks/github",
779 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
780 )
781 .route(
782 "/webhooks/gitlab",
783 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
784 )
785 .route(
786 "/webhooks/bitbucket",
787 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
788 )
789 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
790 .layer(middleware::from_fn_with_state(
791 state.clone(),
792 add_security_headers,
793 ))
794 .layer(build_cors_layer(state.server_mode))
795 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
796 .with_state(state)
797}
798
799pub fn make_test_router() -> Router {
801 std::env::set_var("SLOC_HEADLESS", "1");
803 let tmp = std::env::temp_dir().join("sloc_test");
804 let state = AppState {
805 base_config: AppConfig::default(),
806 artifacts: Arc::new(Mutex::new(HashMap::new())),
807 async_runs: Arc::new(Mutex::new(HashMap::new())),
808 registry: Arc::new(Mutex::new(ScanRegistry::default())),
809 registry_path: tmp.join("registry.json"),
810 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
811 server_mode: false,
812 tls_enabled: false,
813 api_keys: Arc::new(vec![]),
814 rate_limiter: Arc::new(IpRateLimiter::new(
815 Duration::from_mins(1),
816 600,
817 10,
818 Duration::from_hours(1),
819 )),
820 trust_proxy: false,
821 trusted_proxy_ips: vec![],
822 git_clones_dir: tmp.join("git-clones"),
823 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
824 schedules_path: tmp.join("schedules.json"),
825 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
826 scan_profiles_path: tmp.join("scan_profiles.json"),
827 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
828 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
829 confluence_path: tmp.join("confluence_config.json"),
830 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
831 watched_dirs_path: tmp.join("watched_dirs.json"),
832 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
833 cleanup_policy_path: tmp.join("cleanup_policy.json"),
834 cleanup_task_handle: Arc::new(Mutex::new(None)),
835 };
836 build_router(state)
837}
838
839pub fn make_test_router_with_key(api_key: &str) -> Router {
841 let tmp = std::env::temp_dir().join("sloc_test_key");
842 let state = AppState {
843 base_config: AppConfig::default(),
844 artifacts: Arc::new(Mutex::new(HashMap::new())),
845 async_runs: Arc::new(Mutex::new(HashMap::new())),
846 registry: Arc::new(Mutex::new(ScanRegistry::default())),
847 registry_path: tmp.join("registry.json"),
848 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
849 server_mode: false,
850 tls_enabled: false,
851 api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
852 rate_limiter: Arc::new(IpRateLimiter::new(
853 Duration::from_mins(1),
854 600,
855 10,
856 Duration::from_hours(1),
857 )),
858 trust_proxy: false,
859 trusted_proxy_ips: vec![],
860 git_clones_dir: tmp.join("git-clones"),
861 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
862 schedules_path: tmp.join("schedules.json"),
863 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
864 scan_profiles_path: tmp.join("scan_profiles.json"),
865 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
866 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
867 confluence_path: tmp.join("confluence_config.json"),
868 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
869 watched_dirs_path: tmp.join("watched_dirs.json"),
870 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
871 cleanup_policy_path: tmp.join("cleanup_policy.json"),
872 cleanup_task_handle: Arc::new(Mutex::new(None)),
873 };
874 build_router(state)
875}
876
877pub fn make_test_router_server_mode() -> Router {
881 std::env::set_var("SLOC_HEADLESS", "1");
882 let tmp = std::env::temp_dir().join("sloc_test_server");
883 let state = AppState {
884 base_config: AppConfig::default(),
885 artifacts: Arc::new(Mutex::new(HashMap::new())),
886 async_runs: Arc::new(Mutex::new(HashMap::new())),
887 registry: Arc::new(Mutex::new(ScanRegistry::default())),
888 registry_path: tmp.join("registry.json"),
889 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
890 server_mode: true,
891 tls_enabled: false,
892 api_keys: Arc::new(vec![]),
893 rate_limiter: Arc::new(IpRateLimiter::new(
894 Duration::from_mins(1),
895 600,
896 10,
897 Duration::from_hours(1),
898 )),
899 trust_proxy: false,
900 trusted_proxy_ips: vec![],
901 git_clones_dir: tmp.join("git-clones"),
902 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
903 schedules_path: tmp.join("schedules.json"),
904 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
905 scan_profiles_path: tmp.join("scan_profiles.json"),
906 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
907 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
908 confluence_path: tmp.join("confluence_config.json"),
909 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
910 watched_dirs_path: tmp.join("watched_dirs.json"),
911 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
912 cleanup_policy_path: tmp.join("cleanup_policy.json"),
913 cleanup_task_handle: Arc::new(Mutex::new(None)),
914 };
915 build_router(state)
916}
917
918pub fn make_test_router_exhausted_semaphore() -> Router {
921 std::env::set_var("SLOC_HEADLESS", "1");
922 let tmp = std::env::temp_dir().join("sloc_test_exhaust");
923 let sem = Arc::new(tokio::sync::Semaphore::new(0));
924 let state = AppState {
925 base_config: AppConfig::default(),
926 artifacts: Arc::new(Mutex::new(HashMap::new())),
927 async_runs: Arc::new(Mutex::new(HashMap::new())),
928 registry: Arc::new(Mutex::new(ScanRegistry::default())),
929 registry_path: tmp.join("registry.json"),
930 analyze_semaphore: sem,
931 server_mode: false,
932 tls_enabled: false,
933 api_keys: Arc::new(vec![]),
934 rate_limiter: Arc::new(IpRateLimiter::new(
935 Duration::from_mins(1),
936 600,
937 10,
938 Duration::from_hours(1),
939 )),
940 trust_proxy: false,
941 trusted_proxy_ips: vec![],
942 git_clones_dir: tmp.join("git-clones"),
943 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
944 schedules_path: tmp.join("schedules.json"),
945 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
946 scan_profiles_path: tmp.join("scan_profiles.json"),
947 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
948 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
949 confluence_path: tmp.join("confluence_config.json"),
950 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
951 watched_dirs_path: tmp.join("watched_dirs.json"),
952 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
953 cleanup_policy_path: tmp.join("cleanup_policy.json"),
954 cleanup_task_handle: Arc::new(Mutex::new(None)),
955 };
956 build_router(state)
957}
958
959pub fn make_test_router_tight_rate_limit() -> Router {
962 std::env::set_var("SLOC_HEADLESS", "1");
963 let tmp = std::env::temp_dir().join("sloc_test_rate");
964 let state = AppState {
965 base_config: AppConfig::default(),
966 artifacts: Arc::new(Mutex::new(HashMap::new())),
967 async_runs: Arc::new(Mutex::new(HashMap::new())),
968 registry: Arc::new(Mutex::new(ScanRegistry::default())),
969 registry_path: tmp.join("registry.json"),
970 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
971 server_mode: false,
972 tls_enabled: false,
973 api_keys: Arc::new(vec![]),
974 rate_limiter: Arc::new(IpRateLimiter::new(
975 Duration::from_mins(1),
976 2,
977 5,
978 Duration::from_secs(5),
979 )),
980 trust_proxy: false,
981 trusted_proxy_ips: vec![],
982 git_clones_dir: tmp.join("git-clones"),
983 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
984 schedules_path: tmp.join("schedules.json"),
985 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
986 scan_profiles_path: tmp.join("scan_profiles.json"),
987 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
988 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
989 confluence_path: tmp.join("confluence_config.json"),
990 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
991 watched_dirs_path: tmp.join("watched_dirs.json"),
992 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
993 cleanup_policy_path: tmp.join("cleanup_policy.json"),
994 cleanup_task_handle: Arc::new(Mutex::new(None)),
995 };
996 build_router(state)
997}
998
999struct RuntimeSecurityConfig {
1000 api_keys: Vec<secrecy::SecretBox<String>>,
1001 tls_cert: Option<String>,
1002 tls_key: Option<String>,
1003 tls_enabled: bool,
1004 trust_proxy: bool,
1005 trusted_proxy_ips: Vec<IpAddr>,
1006 rate_limiter: Arc<IpRateLimiter>,
1007}
1008
1009fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1010 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1011 .or_else(|_| std::env::var("SLOC_API_KEY"))
1012 .unwrap_or_default()
1013 .split(',')
1014 .map(str::trim)
1015 .filter(|s| !s.is_empty())
1016 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1017 .collect();
1018 if server_mode && api_keys.is_empty() {
1019 println!(
1020 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
1021 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
1022 );
1023 }
1024 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1025 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1026 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1027 if server_mode && !tls_enabled {
1028 println!(
1029 "WARNING: TLS is not configured. Traffic is cleartext. \
1030 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1031 or terminate TLS at a reverse proxy (nginx, caddy)."
1032 );
1033 }
1034 if server_mode {
1035 println!(
1036 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1037 to restrict cross-origin access (comma-separated)."
1038 );
1039 }
1040 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1041 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1042 .unwrap_or_default()
1043 .split(',')
1044 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1045 .collect();
1046 if trust_proxy {
1047 if trusted_proxy_ips.is_empty() {
1048 println!(
1049 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1050 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1051 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1052 );
1053 } else {
1054 println!(
1055 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1056 trusted_proxy_ips
1057 .iter()
1058 .map(std::string::ToString::to_string)
1059 .collect::<Vec<_>>()
1060 .join(", ")
1061 );
1062 }
1063 } else if server_mode {
1064 println!(
1065 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1066 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1067 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1068 enable per-client rate limiting via X-Forwarded-For."
1069 );
1070 }
1071 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1072 println!(
1073 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1074 DISABLED for all git operations. Remove this variable before production use."
1075 );
1076 }
1077 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1078 .ok()
1079 .and_then(|v| v.parse::<u32>().ok())
1080 .unwrap_or(10);
1081 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1082 .ok()
1083 .and_then(|v| v.parse::<u64>().ok())
1084 .unwrap_or(3600);
1085 let default_rpm: usize = if server_mode { 120 } else { 600 };
1089 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1090 .ok()
1091 .and_then(|v| v.parse::<usize>().ok())
1092 .unwrap_or(default_rpm);
1093 let rate_limiter = Arc::new(IpRateLimiter::new(
1094 Duration::from_mins(1),
1095 rate_limit_rpm,
1096 auth_lockout_threshold,
1097 Duration::from_secs(auth_lockout_secs),
1098 ));
1099 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1100 RuntimeSecurityConfig {
1101 api_keys,
1102 tls_cert,
1103 tls_key,
1104 tls_enabled,
1105 trust_proxy,
1106 trusted_proxy_ips,
1107 rate_limiter,
1108 }
1109}
1110
1111#[allow(clippy::too_many_lines)]
1120pub async fn serve(config: AppConfig) -> Result<()> {
1121 let bind_address = config.web.bind_address.clone();
1122 let server_mode = config.web.server_mode;
1123 let output_root = resolve_output_root(None);
1124 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1126 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1127 let mut registry = ScanRegistry::load(®istry_path);
1128 registry.prune_stale();
1129 let _ = registry.save(®istry_path);
1130
1131 let sec = load_runtime_security_config(server_mode);
1132 spawn_upload_staging_cleanup();
1133
1134 let git_clones_dir = resolve_git_clones_dir(&output_root);
1135 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1136 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1137 let schedules = ScheduleStore::load(&schedules_path);
1138 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1139 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1140 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1141 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1142 |_| output_root.join("confluence_config.json"),
1143 PathBuf::from,
1144 );
1145 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1146 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1147 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1148 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1149 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1150 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1151 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1152
1153 let state = AppState {
1154 base_config: config,
1155 artifacts: Arc::new(Mutex::new(HashMap::new())),
1156 async_runs: Arc::new(Mutex::new(HashMap::new())),
1157 registry: Arc::new(Mutex::new(registry)),
1158 registry_path,
1159 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1160 server_mode,
1161 tls_enabled: sec.tls_enabled,
1162 api_keys: Arc::new(sec.api_keys),
1163 rate_limiter: sec.rate_limiter,
1164 trust_proxy: sec.trust_proxy,
1165 trusted_proxy_ips: sec.trusted_proxy_ips,
1166 git_clones_dir,
1167 schedules: Arc::new(Mutex::new(schedules)),
1168 schedules_path,
1169 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1170 scan_profiles_path,
1171 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1172 confluence: Arc::new(Mutex::new(confluence)),
1173 confluence_path,
1174 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1175 watched_dirs_path,
1176 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1177 cleanup_policy_path,
1178 cleanup_task_handle: Arc::new(Mutex::new(None)),
1179 };
1180
1181 restart_poll_schedules(&state).await;
1182
1183 {
1185 let enabled = state
1186 .cleanup_policy
1187 .lock()
1188 .await
1189 .policy
1190 .as_ref()
1191 .is_some_and(|p| p.enabled);
1192 if enabled {
1193 let handle = spawn_cleanup_policy_task(state.clone());
1194 *state.cleanup_task_handle.lock().await = Some(handle);
1195 }
1196 }
1197
1198 let app = build_router(state.clone());
1199
1200 let preferred: SocketAddr = bind_address
1205 .parse()
1206 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1207 let (listener, addr) = {
1208 let candidates = (0u16..=9).map(|offset| {
1209 let mut a = preferred;
1210 a.set_port(preferred.port().saturating_add(offset));
1211 a
1212 });
1213 let mut found = None;
1214 for candidate in candidates {
1215 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1216 found = Some((l, candidate));
1217 break;
1218 }
1219 }
1220 found.ok_or_else(|| {
1221 anyhow::anyhow!(
1222 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1223 bind_address,
1224 preferred.port(),
1225 preferred.port().saturating_add(9)
1226 )
1227 })?
1228 };
1229 if addr != preferred {
1230 eprintln!(
1231 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1232 using {} instead.",
1233 preferred.port(),
1234 addr.port()
1235 );
1236 }
1237
1238 if sec.tls_enabled {
1239 let cert_path = sec
1240 .tls_cert
1241 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1242 let key_path = sec
1243 .tls_key
1244 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1245 let tls_config = build_tls_config(&cert_path, &key_path)
1246 .context("failed to load TLS certificate/key")?;
1247 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1248
1249 let url = format!("https://{addr}/");
1250 println!("OxideSLOC server running at {url} (TLS)");
1251 println!("Use Ctrl+C to stop.");
1252
1253 return serve_tls(listener, app, acceptor, server_mode).await;
1254 }
1255
1256 let url = format!("http://{addr}/");
1257 log_startup_url(&url, server_mode);
1258
1259 axum::serve(
1260 listener,
1261 app.into_make_service_with_connect_info::<SocketAddr>(),
1262 )
1263 .with_graceful_shutdown(shutdown_signal(server_mode))
1264 .await
1265 .context("web server terminated unexpectedly")
1266}
1267
1268fn primary_lan_ip() -> Option<String> {
1272 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1273 socket.connect("8.8.8.8:80").ok()?;
1274 let addr = socket.local_addr().ok()?;
1275 let ip = addr.ip();
1276 if ip.is_loopback() {
1277 return None;
1278 }
1279 Some(ip.to_string())
1280}
1281
1282fn log_startup_url(url: &str, server_mode: bool) {
1284 if server_mode {
1285 println!("OxideSLOC server running at {url}");
1286 println!("Use Ctrl+C to stop.");
1287 } else {
1288 println!("OxideSLOC local web UI running at {url}");
1289 println!("Press Ctrl+C to stop the server.");
1290 let open_url = url.to_owned();
1291 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1292 }
1293}
1294
1295fn open_browser_tab(url: &str) {
1297 #[cfg(target_os = "windows")]
1298 let _ = std::process::Command::new("cmd")
1299 .args(["/c", "start", "", url])
1300 .stdout(Stdio::null())
1301 .stderr(Stdio::null())
1302 .spawn();
1303 #[cfg(target_os = "macos")]
1304 let _ = std::process::Command::new("open")
1305 .arg(url)
1306 .stdout(Stdio::null())
1307 .stderr(Stdio::null())
1308 .spawn();
1309 #[cfg(target_os = "linux")]
1310 let _ = std::process::Command::new("xdg-open")
1311 .arg(url)
1312 .stdout(Stdio::null())
1313 .stderr(Stdio::null())
1314 .spawn();
1315}
1316
1317async fn shutdown_signal(server_mode: bool) {
1319 if tokio::signal::ctrl_c().await.is_ok() {
1320 println!();
1321 if server_mode {
1322 println!("Shutting down OxideSLOC server...");
1323 } else {
1324 println!("Shutting down OxideSLOC local web UI...");
1325 }
1326 println!("Server stopped cleanly.");
1327 }
1328}
1329
1330fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1332 use rustls_pki_types::pem::PemObject;
1333 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1334
1335 let cert_bytes =
1336 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1337 let key_bytes =
1338 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1339
1340 let cert_chain: Vec<CertificateDer<'static>> =
1341 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1342 .collect::<std::result::Result<_, _>>()
1343 .context("failed to parse TLS certificates")?;
1344
1345 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1346 .context("failed to parse TLS private key")?;
1347
1348 rustls::ServerConfig::builder()
1349 .with_no_client_auth()
1350 .with_single_cert(cert_chain, key)
1351 .context("failed to build TLS server config")
1352}
1353
1354async fn serve_tls(
1356 listener: tokio::net::TcpListener,
1357 app: Router,
1358 acceptor: tokio_rustls::TlsAcceptor,
1359 server_mode: bool,
1360) -> Result<()> {
1361 use hyper_util::rt::{TokioExecutor, TokioIo};
1362 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1363 use hyper_util::service::TowerToHyperService;
1364 use tower::{Service, ServiceExt};
1365
1366 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1367
1368 loop {
1369 tokio::select! {
1370 biased;
1371 _ = tokio::signal::ctrl_c() => {
1372 println!();
1373 if server_mode {
1374 println!("Shutting down OxideSLOC server...");
1375 } else {
1376 println!("Shutting down OxideSLOC local web UI...");
1377 }
1378 println!("Server stopped cleanly.");
1379 return Ok(());
1380 }
1381 result = listener.accept() => {
1382 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1383 let acceptor = acceptor.clone();
1384 let mut factory = make_svc.clone();
1385
1386 tokio::spawn(async move {
1387 let tls = match acceptor.accept(tcp).await {
1388 Ok(s) => s,
1389 Err(e) => {
1390 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1391 return;
1392 }
1393 };
1394 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1395 Ok(f) => match Service::call(f, peer_addr).await {
1396 Ok(s) => s,
1397 Err(_) => return,
1398 },
1399 Err(_) => return,
1400 };
1401 let io = TokioIo::new(tls);
1402 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1403 .serve_connection(io, TowerToHyperService::new(svc))
1404 .await
1405 {
1406 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1407 }
1408 });
1409 }
1410 }
1411 }
1412}
1413
1414fn build_cors_layer(server_mode: bool) -> CorsLayer {
1417 if server_mode {
1418 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1419 .unwrap_or_default()
1420 .split(',')
1421 .filter(|s| !s.is_empty())
1422 .filter_map(|s| s.trim().parse().ok())
1423 .collect();
1424 if allowed.is_empty() {
1425 return CorsLayer::new();
1426 }
1427 CorsLayer::new()
1428 .allow_origin(AllowOrigin::list(allowed))
1429 .allow_methods(AllowMethods::list([
1430 axum::http::Method::GET,
1431 axum::http::Method::POST,
1432 ]))
1433 .allow_headers(AllowHeaders::list([
1434 axum::http::header::AUTHORIZATION,
1435 axum::http::header::CONTENT_TYPE,
1436 ]))
1437 } else {
1438 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1439 let s = origin.to_str().unwrap_or("");
1440 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1441 }))
1442 }
1443}
1444
1445async fn add_security_headers(
1446 State(state): State<AppState>,
1447 mut req: Request<Body>,
1448 next: Next,
1449) -> Response {
1450 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1451 req.extensions_mut().insert(CspNonce(nonce.clone()));
1452 let mut resp = next.run(req).await;
1453 let h = resp.headers_mut();
1454 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1455 h.insert(
1456 "X-Content-Type-Options",
1457 HeaderValue::from_static("nosniff"),
1458 );
1459 h.insert(
1460 "Referrer-Policy",
1461 HeaderValue::from_static("strict-origin-when-cross-origin"),
1462 );
1463 let csp = format!(
1464 "default-src 'self'; \
1465 style-src 'self' 'unsafe-inline'; \
1466 img-src 'self' data: blob:; \
1467 script-src 'self' 'nonce-{nonce}'; \
1468 font-src 'self' data:; \
1469 object-src 'none'; \
1470 frame-ancestors 'none'"
1471 );
1472 h.insert(
1473 "Content-Security-Policy",
1474 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1475 HeaderValue::from_static(
1476 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1477 )
1478 }),
1479 );
1480 h.insert(
1481 "X-Permitted-Cross-Domain-Policies",
1482 HeaderValue::from_static("none"),
1483 );
1484 h.insert(
1485 "Permissions-Policy",
1486 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1487 );
1488 h.insert(
1489 "Cross-Origin-Opener-Policy",
1490 HeaderValue::from_static("same-origin"),
1491 );
1492 h.insert(
1493 "Cross-Origin-Resource-Policy",
1494 HeaderValue::from_static("same-origin"),
1495 );
1496 if state.tls_enabled {
1497 h.insert(
1498 "Strict-Transport-Security",
1499 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1500 );
1501 }
1502 resp
1503}
1504
1505async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1506 let peer_ip = req
1507 .extensions()
1508 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1509 .map(|c| c.0.ip());
1510
1511 let ip = peer_ip
1515 .and_then(|peer| {
1516 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1517 req.headers()
1518 .get("X-Forwarded-For")
1519 .and_then(|v| v.to_str().ok())
1520 .and_then(|s| s.split(',').next())
1521 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1522 } else {
1523 None
1524 }
1525 })
1526 .or(peer_ip)
1527 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1528
1529 if !state.rate_limiter.is_allowed(ip) {
1530 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1531 path = %req.uri().path(), "Rate limit exceeded");
1532 return (
1533 StatusCode::TOO_MANY_REQUESTS,
1534 [(header::RETRY_AFTER, "60")],
1535 "429 Too Many Requests\n",
1536 )
1537 .into_response();
1538 }
1539 next.run(req).await
1540}
1541
1542async fn splash(
1543 State(state): State<AppState>,
1544 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1545) -> impl IntoResponse {
1546 let lan_ip = if state.server_mode {
1547 primary_lan_ip()
1548 } else {
1549 None
1550 };
1551 let port = state
1552 .base_config
1553 .web
1554 .bind_address
1555 .rsplit(':')
1556 .next()
1557 .and_then(|p| p.parse::<u16>().ok())
1558 .unwrap_or(4317);
1559 let has_api_key = !state.api_keys.is_empty();
1560 let template = SplashTemplate {
1561 csp_nonce,
1562 server_mode: state.server_mode,
1563 lan_ip,
1564 port,
1565 version: env!("CARGO_PKG_VERSION"),
1566 has_api_key,
1567 };
1568 Html(
1569 template
1570 .render()
1571 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1572 )
1573}
1574
1575async fn index(
1576 State(state): State<AppState>,
1577 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1578 Query(query): Query<IndexQuery>,
1579) -> impl IntoResponse {
1580 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1581 let policy = query
1582 .mixed_line_policy
1583 .unwrap_or_else(|| "code_only".to_string());
1584 let behavior = query
1585 .binary_file_behavior
1586 .unwrap_or_else(|| "skip".to_string());
1587 let cfg = ScanConfig {
1588 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1589 path: query.path.unwrap_or_default(),
1590 include_globs: query.include_globs.unwrap_or_default(),
1591 exclude_globs: query.exclude_globs.unwrap_or_default(),
1592 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1593 mixed_line_policy: policy,
1594 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1595 != Some("off"),
1596 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1597 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1598 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1599 != Some("disabled"),
1600 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1601 binary_file_behavior: behavior,
1602 output_dir: query.output_dir.unwrap_or_default(),
1603 report_title: query.report_title.unwrap_or_default(),
1604 };
1605 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1606 } else {
1607 "{}".to_string()
1608 };
1609
1610 let git_repo = query.git_repo.unwrap_or_default();
1611 let git_ref = query.git_ref.unwrap_or_default();
1612
1613 let git_label = make_git_label(&git_repo, &git_ref);
1614 let git_output_dir = if git_label.is_empty() {
1615 String::new()
1616 } else {
1617 desktop_dir().join(&git_label).display().to_string()
1618 };
1619 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1620 let git_output_dir_json =
1621 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1622
1623 let template = IndexTemplate {
1624 version: env!("CARGO_PKG_VERSION"),
1625 prefill_json,
1626 csp_nonce,
1627 git_repo,
1628 git_ref,
1629 git_label_json,
1630 git_output_dir_json,
1631 server_mode: state.server_mode,
1632 };
1633
1634 Html(
1635 template
1636 .render()
1637 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1638 )
1639}
1640
1641async fn scan_setup_handler(
1642 State(state): State<AppState>,
1643 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1644) -> impl IntoResponse {
1645 let recent_scans_json = {
1646 let arr: Vec<serde_json::Value> = {
1647 let reg = state.registry.lock().await;
1648 reg.entries
1649 .iter()
1650 .rev()
1651 .take(6)
1652 .map(|e| {
1653 let run_dir = e
1654 .html_path
1655 .as_ref()
1656 .or(e.json_path.as_ref())
1657 .and_then(|p| p.parent().map(PathBuf::from));
1658 let config_val: Option<serde_json::Value> = run_dir
1659 .and_then(|d| find_scan_config_in_dir(&d))
1660 .and_then(|p| fs::read_to_string(&p).ok())
1661 .and_then(|s| serde_json::from_str(&s).ok());
1662 serde_json::json!({
1663 "project_label": e.project_label,
1664 "timestamp": fmt_la_time(e.timestamp_utc),
1665 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1666 "config": config_val,
1667 })
1668 })
1669 .collect()
1670 };
1671 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1672 };
1673
1674 let template = ScanSetupTemplate {
1675 version: env!("CARGO_PKG_VERSION"),
1676 recent_scans_json,
1677 csp_nonce,
1678 };
1679 Html(
1680 template
1681 .render()
1682 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1683 )
1684}
1685
1686async fn healthz() -> &'static str {
1687 "ok"
1688}
1689
1690async fn api_version_handler() -> impl IntoResponse {
1691 axum::Json(serde_json::json!({
1692 "name": "oxide-sloc",
1693 "version": env!("CARGO_PKG_VERSION"),
1694 }))
1695}
1696
1697fn prom_runs_total() -> &'static prometheus::IntCounter {
1700 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1701 COUNTER.get_or_init(|| {
1702 prometheus::register_int_counter!(
1703 "oxide_sloc_runs_total",
1704 "Total number of completed analysis runs"
1705 )
1706 .expect("failed to register oxide_sloc_runs_total counter")
1707 })
1708}
1709
1710async fn metrics_handler() -> impl IntoResponse {
1711 use prometheus::Encoder as _;
1712 let mut buf = Vec::new();
1713 let encoder = prometheus::TextEncoder::new();
1714 let _ = encoder.encode(&prometheus::gather(), &mut buf);
1715 (
1716 [(
1717 axum::http::header::CONTENT_TYPE,
1718 "text/plain; version=0.0.4; charset=utf-8",
1719 )],
1720 buf,
1721 )
1722}
1723
1724static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1725
1726async fn openapi_yaml_handler() -> impl IntoResponse {
1727 (
1728 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1729 OPENAPI_YAML,
1730 )
1731}
1732
1733static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
1734static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
1735
1736async fn llms_txt_handler() -> impl IntoResponse {
1737 (
1738 [
1739 (
1740 axum::http::header::CONTENT_TYPE,
1741 "text/plain; charset=utf-8",
1742 ),
1743 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1744 ],
1745 LLMS_TXT,
1746 )
1747}
1748
1749async fn llms_full_txt_handler() -> impl IntoResponse {
1750 (
1751 [
1752 (
1753 axum::http::header::CONTENT_TYPE,
1754 "text/plain; charset=utf-8",
1755 ),
1756 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1757 ],
1758 LLMS_FULL_TXT,
1759 )
1760}
1761
1762async fn api_docs_handler(
1763 State(state): State<AppState>,
1764 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1765) -> impl IntoResponse {
1766 let has_api_key = !state.api_keys.is_empty();
1767 Html(
1768 ApiDocsTemplate {
1769 has_api_key,
1770 csp_nonce,
1771 version: env!("CARGO_PKG_VERSION"),
1772 }
1773 .render()
1774 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1775 )
1776}
1777
1778async fn chart_js_handler() -> impl IntoResponse {
1779 (
1780 [
1781 (
1782 header::CONTENT_TYPE,
1783 "application/javascript; charset=utf-8",
1784 ),
1785 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1786 ],
1787 CHART_JS,
1788 )
1789}
1790
1791async fn report_chart_js_handler() -> impl IntoResponse {
1792 (
1793 [
1794 (
1795 header::CONTENT_TYPE,
1796 "application/javascript; charset=utf-8",
1797 ),
1798 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1799 ],
1800 REPORT_CHART_JS,
1801 )
1802}
1803
1804#[derive(Debug, Deserialize)]
1805struct AnalyzeForm {
1806 path: String,
1807 git_repo: Option<String>,
1808 git_ref: Option<String>,
1809 mixed_line_policy: Option<MixedLinePolicy>,
1810 python_docstrings_as_comments: Option<String>,
1811 generated_file_detection: Option<String>,
1812 minified_file_detection: Option<String>,
1813 vendor_directory_detection: Option<String>,
1814 include_lockfiles: Option<String>,
1815 binary_file_behavior: Option<BinaryFileBehavior>,
1816 output_dir: Option<String>,
1817 report_title: Option<String>,
1818 report_header_footer: Option<String>,
1819 include_globs: Option<String>,
1820 exclude_globs: Option<String>,
1821 submodule_breakdown: Option<String>,
1822 coverage_file: Option<String>,
1823 continuation_line_policy: Option<ContinuationLinePolicy>,
1824 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1825 count_compiler_directives: Option<String>,
1826 style_col_threshold: Option<String>,
1827 style_analysis_enabled: Option<String>,
1828 style_score_threshold: Option<String>,
1829 style_lang_scope: Option<String>,
1830 cocomo_mode: Option<String>,
1832 complexity_alert: Option<String>,
1834 exclude_duplicates: Option<String>,
1836}
1837
1838#[allow(clippy::struct_excessive_bools)]
1839#[derive(Debug, Serialize, Deserialize, Clone)]
1840struct ScanConfig {
1841 oxide_sloc_version: String,
1842 path: String,
1843 include_globs: String,
1844 exclude_globs: String,
1845 submodule_breakdown: bool,
1846 mixed_line_policy: String,
1847 python_docstrings_as_comments: bool,
1848 generated_file_detection: bool,
1849 minified_file_detection: bool,
1850 vendor_directory_detection: bool,
1851 include_lockfiles: bool,
1852 binary_file_behavior: String,
1853 output_dir: String,
1854 report_title: String,
1855}
1856
1857#[derive(Debug, Deserialize, Default)]
1858struct IndexQuery {
1859 path: Option<String>,
1860 include_globs: Option<String>,
1861 exclude_globs: Option<String>,
1862 submodule_breakdown: Option<String>,
1863 mixed_line_policy: Option<String>,
1864 python_docstrings_as_comments: Option<String>,
1865 generated_file_detection: Option<String>,
1866 minified_file_detection: Option<String>,
1867 vendor_directory_detection: Option<String>,
1868 include_lockfiles: Option<String>,
1869 binary_file_behavior: Option<String>,
1870 output_dir: Option<String>,
1871 report_title: Option<String>,
1872 prefilled: Option<String>,
1873 git_repo: Option<String>,
1874 git_ref: Option<String>,
1875}
1876
1877#[derive(Debug, Deserialize)]
1878struct PreviewQuery {
1879 path: Option<String>,
1880 include_globs: Option<String>,
1881 exclude_globs: Option<String>,
1882}
1883
1884#[cfg(feature = "native-dialog")]
1885#[derive(Debug, Deserialize)]
1886struct PickDirectoryQuery {
1887 kind: Option<String>,
1888 current: Option<String>,
1889}
1890
1891#[cfg(not(feature = "native-dialog"))]
1892#[derive(Debug, Deserialize)]
1893struct PickDirectoryQuery {}
1894
1895#[derive(Debug, Deserialize, Default)]
1896struct ArtifactQuery {
1897 download: Option<String>,
1898}
1899
1900#[cfg(feature = "native-dialog")]
1901#[derive(Debug, Serialize)]
1902struct PickDirectoryResponse {
1903 selected_path: Option<String>,
1904 cancelled: bool,
1905}
1906
1907#[cfg(feature = "native-dialog")]
1908async fn pick_directory_handler(
1909 State(state): State<AppState>,
1910 Query(query): Query<PickDirectoryQuery>,
1911) -> Response {
1912 if state.server_mode {
1913 return StatusCode::NOT_FOUND.into_response();
1914 }
1915 if std::env::var("SLOC_HEADLESS").is_ok() {
1917 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1918 .into_response();
1919 }
1920
1921 let is_coverage = query.kind.as_deref() == Some("coverage");
1922 let title = match query.kind.as_deref() {
1923 Some("output") => "Select output directory",
1924 Some("reports") => "Select folder containing saved reports",
1925 Some("coverage") => "Select LCOV coverage file",
1926 _ => "Select project directory",
1927 }
1928 .to_owned();
1929 let current = query.current.clone();
1930
1931 let picked = tokio::task::spawn_blocking(move || {
1932 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1935 let fg_tid = win_dialog_focus::attach_to_foreground();
1936 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1937 win_dialog_focus::flash_dialog_when_ready(title.clone());
1938
1939 let mut dialog = rfd::FileDialog::new().set_title(&title);
1940 if let Some(current) = current.as_deref() {
1941 let resolved = resolve_input_path(current);
1942 let seed = if resolved.is_dir() {
1943 Some(resolved)
1944 } else {
1945 resolved.parent().map(Path::to_path_buf)
1946 };
1947 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1948 dialog = dialog.set_directory(seed_dir);
1949 }
1950 }
1951 let result = if is_coverage {
1952 dialog
1953 .add_filter(
1954 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1955 &["info", "lcov", "xml"],
1956 )
1957 .pick_file()
1958 } else {
1959 dialog.pick_folder()
1960 };
1961
1962 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1963 win_dialog_focus::detach_from_foreground(fg_tid);
1964
1965 result
1966 })
1967 .await
1968 .unwrap_or(None);
1969
1970 Json(PickDirectoryResponse {
1971 selected_path: picked.as_ref().map(|p| display_path(p)),
1972 cancelled: picked.is_none(),
1973 })
1974 .into_response()
1975}
1976
1977#[cfg(not(feature = "native-dialog"))]
1978async fn pick_directory_handler(
1979 State(_state): State<AppState>,
1980 Query(_query): Query<PickDirectoryQuery>,
1981) -> Response {
1982 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
1983}
1984
1985#[cfg(feature = "native-dialog")]
1986async fn pick_file_handler(State(state): State<AppState>) -> Response {
1987 if state.server_mode {
1988 return StatusCode::NOT_FOUND.into_response();
1989 }
1990 if std::env::var("SLOC_HEADLESS").is_ok() {
1991 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1992 .into_response();
1993 }
1994 let picked = tokio::task::spawn_blocking(|| {
1995 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1996 let fg_tid = win_dialog_focus::attach_to_foreground();
1997 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1998 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1999
2000 let result = rfd::FileDialog::new()
2001 .set_title("Select HTML report")
2002 .add_filter("HTML report", &["html"])
2003 .pick_file();
2004
2005 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2006 win_dialog_focus::detach_from_foreground(fg_tid);
2007
2008 result
2009 })
2010 .await
2011 .unwrap_or(None);
2012 Json(PickDirectoryResponse {
2013 selected_path: picked.as_ref().map(|p| display_path(p)),
2014 cancelled: picked.is_none(),
2015 })
2016 .into_response()
2017}
2018
2019#[cfg(not(feature = "native-dialog"))]
2020async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2021 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2022}
2023
2024fn is_upload_tmp_path(path: &Path) -> bool {
2029 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2030 path.starts_with(&upload_root)
2031}
2032
2033fn is_sample_path(path: &Path) -> bool {
2036 let root = workspace_root();
2037 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2038}
2039
2040fn upload_base_dir() -> PathBuf {
2042 std::env::temp_dir().join("oxide-sloc-uploads")
2043}
2044
2045fn upload_staging_path(id: &str) -> PathBuf {
2047 upload_base_dir().join(id)
2048}
2049
2050#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2054 const MAX_FILES: usize = 50_000;
2055 if body.files.is_empty() {
2056 return Err((
2057 StatusCode::BAD_REQUEST,
2058 Json(serde_json::json!({"error": "No files received"})),
2059 )
2060 .into_response());
2061 }
2062 if body.files.len() > MAX_FILES {
2063 return Err((
2064 StatusCode::PAYLOAD_TOO_LARGE,
2065 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2066 )
2067 .into_response());
2068 }
2069 Ok(())
2070}
2071
2072fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2075 match id {
2076 Some(id)
2077 if !id.is_empty()
2078 && id.len() <= 36
2079 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2080 {
2081 (id.to_string(), upload_staging_path(id))
2082 }
2083 _ => {
2084 let new_id = uuid::Uuid::new_v4().to_string();
2085 let staging = upload_staging_path(&new_id);
2086 (new_id, staging)
2087 }
2088 }
2089}
2090
2091#[allow(clippy::result_large_err)]
2096async fn stage_decoded_entry(
2097 entry: &UploadedFile,
2098 staging: &Path,
2099 total_bytes: &mut usize,
2100 project_root: &mut Option<PathBuf>,
2101) -> Result<(), Response> {
2102 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2103
2104 let Ok(data) = base64::Engine::decode(
2105 &base64::engine::general_purpose::STANDARD,
2106 entry.content.as_bytes(),
2107 ) else {
2108 return Ok(());
2109 };
2110
2111 *total_bytes += data.len();
2112 if *total_bytes > MAX_TOTAL_BYTES {
2113 return Err((
2114 StatusCode::PAYLOAD_TOO_LARGE,
2115 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2116 )
2117 .into_response());
2118 }
2119
2120 let rel = std::path::Path::new(&entry.path);
2121 if project_root.is_none() {
2122 if let Some(first) = rel.components().next() {
2123 *project_root = Some(staging.join(first.as_os_str()));
2124 }
2125 }
2126
2127 let dest = staging.join(rel);
2128 if let Some(parent) = dest.parent() {
2129 if tokio::fs::create_dir_all(parent).await.is_err() {
2130 return Err((
2131 StatusCode::INTERNAL_SERVER_ERROR,
2132 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2133 )
2134 .into_response());
2135 }
2136 }
2137
2138 if tokio::fs::write(&dest, &data).await.is_err() {
2139 return Err((
2140 StatusCode::INTERNAL_SERVER_ERROR,
2141 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2142 )
2143 .into_response());
2144 }
2145
2146 Ok(())
2147}
2148
2149async fn write_upload_files(
2153 files: &[UploadedFile],
2154 staging: &Path,
2155 upload_id: &str,
2156) -> Result<(usize, Option<PathBuf>), Response> {
2157 let mut total_bytes: usize = 0;
2158 let mut project_root: Option<PathBuf> = None;
2159 let mut traversal_attempts: usize = 0;
2160
2161 for entry in files {
2162 let rel = std::path::Path::new(&entry.path);
2163 if rel
2164 .components()
2165 .any(|c| matches!(c, std::path::Component::ParentDir))
2166 {
2167 traversal_attempts += 1;
2168 if traversal_attempts >= 5 {
2169 let _ = tokio::fs::remove_dir_all(staging).await;
2170 tracing::warn!(
2171 event = "upload_path_traversal",
2172 upload_id = %upload_id,
2173 "Upload rejected: repeated path traversal attempts detected"
2174 );
2175 return Err((
2176 StatusCode::BAD_REQUEST,
2177 Json(serde_json::json!({"error": "Upload rejected"})),
2178 )
2179 .into_response());
2180 }
2181 continue;
2182 }
2183
2184 if let Err(resp) =
2185 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2186 {
2187 let _ = tokio::fs::remove_dir_all(staging).await;
2188 return Err(resp);
2189 }
2190 }
2191
2192 Ok((files.len(), project_root))
2193}
2194
2195fn parse_tarball_size_caps() -> (u64, u64) {
2198 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2199 .ok()
2200 .and_then(|v| v.parse().ok())
2201 .unwrap_or(2048_u64)
2202 * 1024
2203 * 1024;
2204 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2205 .ok()
2206 .and_then(|v| v.parse().ok())
2207 .unwrap_or(10_240_u64)
2208 * 1024
2209 * 1024;
2210 (compressed, decompressed)
2211}
2212
2213#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2218 body: axum::body::Body,
2219 dest_path: &Path,
2220 max_bytes: u64,
2221) -> Result<u64, Response> {
2222 use http_body_util::BodyExt as _;
2223 use tokio::io::AsyncWriteExt as _;
2224
2225 let mut file = match tokio::fs::File::create(dest_path).await {
2226 Ok(f) => f,
2227 Err(e) => {
2228 tracing::error!(
2229 event = "upload_io_error",
2230 "failed to create tarball temp file: {e}"
2231 );
2232 return Err((
2233 StatusCode::INTERNAL_SERVER_ERROR,
2234 Json(serde_json::json!({"error": "Upload initialization failed"})),
2235 )
2236 .into_response());
2237 }
2238 };
2239
2240 let mut body = body;
2241 let mut written: u64 = 0;
2242 loop {
2243 match body.frame().await {
2244 None => break,
2245 Some(Err(e)) => {
2246 let _ = tokio::fs::remove_file(dest_path).await;
2247 return Err((
2248 StatusCode::BAD_REQUEST,
2249 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2250 )
2251 .into_response());
2252 }
2253 Some(Ok(frame)) => {
2254 if let Ok(data) = frame.into_data() {
2255 written += data.len() as u64;
2256 if written > max_bytes {
2257 let _ = tokio::fs::remove_file(dest_path).await;
2258 return Err((
2259 StatusCode::PAYLOAD_TOO_LARGE,
2260 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2261 )
2262 .into_response());
2263 }
2264 if let Err(e) = file.write_all(&data).await {
2265 let _ = tokio::fs::remove_file(dest_path).await;
2266 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2267 return Err((
2268 StatusCode::INTERNAL_SERVER_ERROR,
2269 Json(serde_json::json!({"error": "Upload write failed"})),
2270 )
2271 .into_response());
2272 }
2273 }
2274 }
2275 }
2276 }
2277 drop(file);
2278 Ok(written)
2279}
2280
2281#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2286 tarball_path: &Path,
2287 staging: &Path,
2288 max_decompressed_bytes: u64,
2289) -> Result<(), Response> {
2290 let staging_clone = staging.to_path_buf();
2291 let tarball_clone = tarball_path.to_path_buf();
2292 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2293 let file = std::fs::File::open(&tarball_clone)?;
2294 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2295 let limited = SizeLimitReader {
2296 inner: gz,
2297 remaining: max_decompressed_bytes,
2298 };
2299 let mut archive = tar::Archive::new(limited);
2300 archive.set_overwrite(true);
2301 archive.set_preserve_permissions(false);
2302 std::fs::create_dir_all(&staging_clone)?;
2303 archive.unpack(&staging_clone)?;
2304 Ok(())
2305 })
2306 .await;
2307 let _ = tokio::fs::remove_file(tarball_path).await;
2308
2309 match extract_result {
2310 Ok(Ok(())) => Ok(()),
2311 Ok(Err(e)) => {
2312 let _ = tokio::fs::remove_dir_all(staging).await;
2313 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2314 tracing::warn!(
2315 event = "upload_extract_error",
2316 "tarball extraction failed: {e:#}"
2317 );
2318 let (status, msg) = if is_size_limit {
2319 (
2320 StatusCode::PAYLOAD_TOO_LARGE,
2321 "Archive exceeds the decompressed size limit",
2322 )
2323 } else {
2324 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2325 };
2326 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2327 }
2328 Err(e) => {
2329 let _ = tokio::fs::remove_dir_all(staging).await;
2330 tracing::error!(
2331 event = "upload_extract_panic",
2332 "tarball extraction task panicked: {e}"
2333 );
2334 Err((
2335 StatusCode::INTERNAL_SERVER_ERROR,
2336 Json(serde_json::json!({"error": "Archive extraction failed"})),
2337 )
2338 .into_response())
2339 }
2340 }
2341}
2342
2343async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2347 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2348 let first = entries.next_entry().await.ok()??;
2349 if !first.path().is_dir() {
2350 return None;
2351 }
2352 if entries.next_entry().await.unwrap_or(None).is_some() {
2353 return None;
2354 }
2355 Some(first.path())
2356}
2357
2358#[derive(Deserialize)]
2365struct UploadDirRequest {
2366 files: Vec<UploadedFile>,
2367 upload_id: Option<String>,
2370}
2371
2372#[derive(Deserialize)]
2373struct UploadedFile {
2374 path: String,
2376 content: String,
2378}
2379
2380async fn upload_directory_handler(
2390 State(state): State<AppState>,
2391 Json(body): Json<UploadDirRequest>,
2392) -> Response {
2393 if !state.server_mode {
2394 return StatusCode::NOT_FOUND.into_response();
2395 }
2396 if let Err(resp) = validate_upload_dir_request(&body) {
2397 return resp;
2398 }
2399 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2402 match write_upload_files(&body.files, &staging, &upload_id).await {
2403 Ok((file_count, project_root)) => {
2404 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2405 Json(serde_json::json!({
2406 "tmp_path": scan_root.to_string_lossy(),
2407 "file_count": file_count,
2408 "upload_id": upload_id.clone()
2409 }))
2410 .into_response()
2411 }
2412 Err(resp) => resp,
2413 }
2414}
2415
2416#[derive(Deserialize)]
2418struct UploadFileRequest {
2419 filename: String,
2421 content: String,
2423}
2424
2425async fn upload_file_handler(
2431 State(state): State<AppState>,
2432 Json(body): Json<UploadFileRequest>,
2433) -> Response {
2434 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2437 return StatusCode::NOT_FOUND.into_response();
2438 }
2439
2440 let Ok(data) = base64::Engine::decode(
2441 &base64::engine::general_purpose::STANDARD,
2442 body.content.as_bytes(),
2443 ) else {
2444 return (
2445 StatusCode::BAD_REQUEST,
2446 Json(serde_json::json!({"error": "Invalid base64 content"})),
2447 )
2448 .into_response();
2449 };
2450
2451 if data.len() > MAX_FILE_BYTES {
2452 return (
2453 StatusCode::PAYLOAD_TOO_LARGE,
2454 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2455 )
2456 .into_response();
2457 }
2458
2459 let filename = std::path::Path::new(&body.filename)
2461 .file_name()
2462 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2463
2464 let upload_id = uuid::Uuid::new_v4();
2465 let staging = std::env::temp_dir()
2466 .join("oxide-sloc-uploads")
2467 .join(upload_id.to_string());
2468
2469 if tokio::fs::create_dir_all(&staging).await.is_err() {
2470 return (
2471 StatusCode::INTERNAL_SERVER_ERROR,
2472 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2473 )
2474 .into_response();
2475 }
2476
2477 let dest = staging.join(&filename);
2478 if tokio::fs::write(&dest, &data).await.is_err() {
2479 let _ = tokio::fs::remove_dir_all(&staging).await;
2480 return (
2481 StatusCode::INTERNAL_SERVER_ERROR,
2482 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2483 )
2484 .into_response();
2485 }
2486
2487 Json(serde_json::json!({
2488 "tmp_path": dest.to_string_lossy(),
2489 "upload_id": upload_id.to_string()
2490 }))
2491 .into_response()
2492}
2493
2494struct SizeLimitReader<R> {
2509 inner: R,
2510 remaining: u64,
2511}
2512impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2513 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2514 if self.remaining == 0 {
2515 return Err(std::io::Error::other("decompressed size limit exceeded"));
2516 }
2517 let n = self.inner.read(buf)?;
2518 self.remaining = self.remaining.saturating_sub(n as u64);
2519 Ok(n)
2520 }
2521}
2522
2523async fn upload_tarball_handler(
2524 State(state): State<AppState>,
2525 request: axum::extract::Request,
2526) -> Response {
2527 if !state.server_mode {
2528 return StatusCode::NOT_FOUND.into_response();
2529 }
2530
2531 let upload_id = uuid::Uuid::new_v4().to_string();
2532 let upload_base = upload_base_dir();
2533 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2534 let staging = upload_staging_path(&upload_id);
2535 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2536
2537 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2538 tracing::error!(
2539 event = "upload_io_error",
2540 "failed to create upload base dir: {e}"
2541 );
2542 return (
2543 StatusCode::INTERNAL_SERVER_ERROR,
2544 Json(serde_json::json!({"error": "Upload initialization failed"})),
2545 )
2546 .into_response();
2547 }
2548
2549 let compressed_bytes =
2551 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2552 Ok(n) => n,
2553 Err(resp) => return resp,
2554 };
2555
2556 if let Err(resp) =
2558 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2559 {
2560 return resp;
2561 }
2562
2563 let scan_root = find_single_top_dir(&staging)
2568 .await
2569 .unwrap_or_else(|| staging.clone());
2570
2571 let original_bytes = tokio::task::spawn_blocking({
2573 let p = scan_root.clone();
2574 move || dir_size_bytes(&p)
2575 })
2576 .await
2577 .unwrap_or(0);
2578
2579 Json(serde_json::json!({
2580 "tmp_path": scan_root.to_string_lossy(),
2581 "upload_id": upload_id,
2582 "compressed_bytes": compressed_bytes,
2583 "original_bytes": original_bytes,
2584 }))
2585 .into_response()
2586}
2587
2588#[derive(Deserialize)]
2589struct LocateReportForm {
2590 file_path: String,
2591 #[serde(default)]
2592 redirect_url: Option<String>,
2593 #[serde(default)]
2594 expected_run_id: Option<String>,
2595}
2596
2597fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2599 let html = ErrorTemplate {
2600 message: message.into(),
2601 last_report_url: Some("/view-reports".to_string()),
2602 last_report_label: Some("View Reports".to_string()),
2603 run_id: None,
2604 error_code: None,
2605 csp_nonce: csp_nonce.to_owned(),
2606 version: env!("CARGO_PKG_VERSION"),
2607 }
2608 .render()
2609 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2610 Html(html).into_response()
2611}
2612
2613fn registry_entry_from_run(
2615 run: &AnalysisRun,
2616 json_path: PathBuf,
2617 html_path: PathBuf,
2618) -> RegistryEntry {
2619 let project_label = run.input_roots.first().map_or_else(
2620 || "Unknown Project".to_string(),
2621 |r| sanitize_project_label(r),
2622 );
2623 RegistryEntry {
2624 run_id: run.tool.run_id.clone(),
2625 timestamp_utc: run.tool.timestamp_utc,
2626 project_label,
2627 input_roots: run.input_roots.clone(),
2628 json_path: Some(json_path),
2629 html_path: Some(html_path),
2630 pdf_path: None,
2631 summary: ScanSummarySnapshot {
2632 files_analyzed: run.summary_totals.files_analyzed,
2633 files_skipped: run.summary_totals.files_skipped,
2634 total_physical_lines: run.summary_totals.total_physical_lines,
2635 code_lines: run.summary_totals.code_lines,
2636 comment_lines: run.summary_totals.comment_lines,
2637 blank_lines: run.summary_totals.blank_lines,
2638 functions: run.summary_totals.functions,
2639 classes: run.summary_totals.classes,
2640 variables: run.summary_totals.variables,
2641 imports: run.summary_totals.imports,
2642 test_count: run.summary_totals.test_count,
2643 coverage_lines_found: run.summary_totals.coverage_lines_found,
2644 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2645 coverage_functions_found: run.summary_totals.coverage_functions_found,
2646 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2647 coverage_branches_found: run.summary_totals.coverage_branches_found,
2648 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2649 },
2650 csv_path: None,
2651 xlsx_path: None,
2652 git_branch: None,
2653 git_commit: None,
2654 git_author: None,
2655 git_tags: None,
2656 git_nearest_tag: None,
2657 git_commit_date: None,
2658 }
2659}
2660
2661pub(crate) async fn register_artifacts_in_registry(
2664 state: &AppState,
2665 label: &str,
2666 run: &AnalysisRun,
2667 artifacts: &RunArtifacts,
2668) {
2669 let Some(json_path) = artifacts.json_path.clone() else {
2670 return;
2671 };
2672 let Some(html_path) = artifacts.html_path.clone() else {
2673 return;
2674 };
2675 let mut entry = registry_entry_from_run(run, json_path, html_path);
2676 entry.project_label = label.to_owned();
2677 let mut reg = state.registry.lock().await;
2678 reg.add_entry(entry);
2679 let _ = reg.save(&state.registry_path);
2680}
2681
2682fn is_html_report_file(p: &Path) -> bool {
2683 p.is_file()
2684 && p.extension()
2685 .and_then(|x| x.to_str())
2686 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2687 && p.file_name()
2688 .and_then(|n| n.to_str())
2689 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2690}
2691
2692fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2693 fs::read_dir(dir)
2694 .ok()?
2695 .flatten()
2696 .map(|e| e.path())
2697 .find(|p| is_html_report_file(p))
2698}
2699
2700fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2701 if let Some(f) = find_html_report_in_dir(dir) {
2702 return Some(f);
2703 }
2704 if let Ok(rd) = fs::read_dir(dir) {
2705 for entry in rd.flatten() {
2706 let sub = entry.path();
2707 if sub.is_dir() {
2708 if let Some(f) = find_html_report_in_dir(&sub) {
2709 return Some(f);
2710 }
2711 }
2712 }
2713 }
2714 None
2715}
2716
2717#[allow(clippy::result_large_err)]
2722fn validate_locate_request(
2723 state: &AppState,
2724 file_path: &str,
2725 csp_nonce: &str,
2726) -> Result<(PathBuf, PathBuf), Response> {
2727 let raw = PathBuf::from(file_path);
2728
2729 let html_path = if raw.is_dir() {
2731 let found = find_html_report_in_tree(&raw);
2732 match found {
2733 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2734 None => {
2735 return Err(locate_report_error(
2736 "No HTML report file found in the selected folder.\n\nMake sure you selected \
2737 the folder that contains your scan output (result_*.html or report_*.html).",
2738 csp_nonce,
2739 ));
2740 }
2741 }
2742 } else {
2743 let file_ext = raw
2744 .extension()
2745 .and_then(|e| e.to_str())
2746 .unwrap_or("")
2747 .to_ascii_lowercase();
2748 if file_ext != "html" {
2749 return Err(locate_report_error(
2750 "Please select the scan output folder, or an .html report file directly.",
2751 csp_nonce,
2752 ));
2753 }
2754 match fs::canonicalize(&raw) {
2755 Ok(p) => strip_unc_prefix(p),
2756 Err(_) => {
2757 return Err(locate_report_error(
2758 "Report file not found or path is invalid.",
2759 csp_nonce,
2760 ));
2761 }
2762 }
2763 };
2764
2765 if state.server_mode {
2766 let output_root = resolve_output_root(None);
2767 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2768 if !html_path.starts_with(&canonical_root) {
2769 return Err(locate_report_error(
2770 "Report file must be within the configured output directory.",
2771 csp_nonce,
2772 ));
2773 }
2774 }
2775 let parent = match html_path.parent() {
2776 Some(p) => p.to_path_buf(),
2777 None => {
2778 return Err(locate_report_error(
2779 "Report file has no parent directory.",
2780 csp_nonce,
2781 ));
2782 }
2783 };
2784 Ok((html_path, parent))
2785}
2786
2787fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2789 if want_json {
2790 (
2791 StatusCode::UNPROCESSABLE_ENTITY,
2792 axum::Json(serde_json::json!({"ok": false, "message": msg})),
2793 )
2794 .into_response()
2795 } else {
2796 locate_report_error(msg, csp_nonce)
2797 }
2798}
2799
2800fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2802 if want_json {
2803 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2804 } else {
2805 axum::response::Redirect::to(redirect).into_response()
2806 }
2807}
2808
2809fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2812 for jpath in candidates {
2813 if let Ok(run) = read_json(jpath) {
2814 if expected.is_empty() || run.tool.run_id == expected {
2815 return Some((jpath.clone(), run.tool.run_id));
2816 }
2817 }
2818 }
2819 None
2820}
2821
2822fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2823 html_path
2824 .parent()
2825 .and_then(|p| p.parent())
2826 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2827}
2828
2829fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2830 let mut hits = collect_result_json_candidates(scan_root);
2831 if hits.is_empty() {
2832 hits = collect_result_json_candidates(parent);
2833 }
2834 hits.sort();
2835 hits
2836}
2837
2838#[allow(clippy::too_many_lines)]
2839async fn locate_report_handler(
2840 State(state): State<AppState>,
2841 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2842 headers: axum::http::HeaderMap,
2843 Form(form): Form<LocateReportForm>,
2844) -> impl IntoResponse {
2845 let want_json = headers
2846 .get(axum::http::header::ACCEPT)
2847 .and_then(|v| v.to_str().ok())
2848 .is_some_and(|v| v.contains("application/json"));
2849
2850 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2851 Ok(v) => v,
2852 Err(resp) => {
2853 if want_json {
2854 return locate_handler_err(
2855 true,
2856 "No HTML report file found in the selected folder. \
2857 Make sure you selected the folder that contains your \
2858 scan output (look for the folder with html/, json/, pdf/ subdirs)."
2859 .to_string(),
2860 &csp_nonce,
2861 );
2862 }
2863 return resp;
2864 }
2865 };
2866
2867 let scan_root_owned = resolve_scan_root(&html_path, &parent);
2870 let scan_root: &Path = &scan_root_owned;
2871 let json_candidates = gather_json_candidates(scan_root, &parent);
2872
2873 let expected_run_id = form
2875 .expected_run_id
2876 .as_deref()
2877 .unwrap_or("")
2878 .trim()
2879 .to_string();
2880
2881 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2882
2883 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2885 let actual = json_candidates
2886 .iter()
2887 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2888 .unwrap_or_else(|| "unknown".to_string());
2889 return locate_handler_err(
2890 want_json,
2891 format!(
2892 "This folder contains a different scan.\n\n\
2893 Expected run ID : {expected_run_id}\n\
2894 Found run ID : {actual}\n\n\
2895 Please select the folder that contains the correct scan output."
2896 ),
2897 &csp_nonce,
2898 );
2899 }
2900
2901 let safe_redirect = form
2902 .redirect_url
2903 .as_deref()
2904 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2905 .unwrap_or("/view-reports?linked=1")
2906 .to_string();
2907
2908 let mut reg = state.registry.lock().await;
2909
2910 if let Some((json_path, run_id)) = matched_json {
2911 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2913 entry.html_path = Some(html_path);
2914 entry.json_path = Some(json_path);
2915 let _ = reg.save(&state.registry_path);
2916 drop(reg);
2917 state.artifacts.lock().await.remove(&run_id);
2919 return redirect_or_json_ok(want_json, &safe_redirect);
2920 }
2921 match read_json(&json_path) {
2923 Ok(run) => {
2924 let entry = registry_entry_from_run(&run, json_path, html_path);
2925 reg.add_entry(entry);
2926 let _ = reg.save(&state.registry_path);
2927 drop(reg);
2928 state.artifacts.lock().await.remove(&run_id);
2929 return redirect_or_json_ok(want_json, &safe_redirect);
2930 }
2931 Err(e) => {
2932 drop(reg);
2933 return locate_handler_err(
2934 want_json,
2935 format!(
2936 "Found the scan folder but could not parse the result JSON.\n\n\
2937 The file may have been saved by an older version of OxideSLOC. \
2938 Re-running the analysis will create a fresh, compatible record.\n\n\
2939 Error: {e}"
2940 ),
2941 &csp_nonce,
2942 );
2943 }
2944 }
2945 }
2946
2947 if let Some(entry) = reg
2949 .entries
2950 .iter_mut()
2951 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2952 {
2953 entry.html_path = Some(html_path.clone());
2954 let _ = reg.save(&state.registry_path);
2955 drop(reg);
2956 state.artifacts.lock().await.remove(&expected_run_id);
2957 return redirect_or_json_ok(want_json, &safe_redirect);
2958 }
2959
2960 drop(reg);
2961 let hint = if state.server_mode {
2962 String::new()
2963 } else {
2964 format!(
2965 "\n\nSearched folder : {}\nHTML found : {}",
2966 scan_root.display(),
2967 html_path.display()
2968 )
2969 };
2970 locate_handler_err(
2971 want_json,
2972 format!(
2973 "Could not link this report.\n\n\
2974 No result_*.json was found in the selected folder. \
2975 Make sure you selected the top-level scan output folder \
2976 (the one that contains html/, json/, pdf/ subfolders).{hint}"
2977 ),
2978 &csp_nonce,
2979 )
2980}
2981
2982fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
2984 fs::read_dir(dir)
2985 .ok()?
2986 .flatten()
2987 .map(|e| e.path())
2988 .find(|p| {
2989 p.is_file()
2990 && p.file_stem()
2991 .and_then(|n| n.to_str())
2992 .is_some_and(|n| n.starts_with("result"))
2993 && p.extension()
2994 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
2995 })
2996}
2997
2998#[derive(Deserialize)]
2999struct LocateReportsDirForm {
3000 folder_path: String,
3001}
3002
3003#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3005 State(state): State<AppState>,
3006 Form(form): Form<LocateReportsDirForm>,
3007) -> impl IntoResponse {
3008 if state.server_mode {
3009 return StatusCode::NOT_FOUND.into_response();
3010 }
3011 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3012 Ok(p) => strip_unc_prefix(p),
3013 Err(_) => {
3014 return axum::response::Redirect::to(
3015 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3016 )
3017 .into_response();
3018 }
3019 };
3020 if !folder.is_dir() {
3021 return axum::response::Redirect::to(
3022 "/view-reports?error=Selected+path+is+not+a+directory.",
3023 )
3024 .into_response();
3025 }
3026
3027 let candidates = collect_result_json_candidates(&folder);
3028
3029 if candidates.is_empty() {
3030 return axum::response::Redirect::to(
3031 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3032 )
3033 .into_response();
3034 }
3035
3036 let mut linked_count: usize = 0;
3037 let mut reg = state.registry.lock().await;
3038 for json_path in candidates {
3039 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3040 continue;
3041 };
3042 if is_dir_already_registered(®, &parent) {
3043 continue;
3044 }
3045 let Some(entry) = build_registry_entry_from_json(json_path) else {
3046 continue;
3047 };
3048 reg.add_entry(entry);
3049 linked_count += 1;
3050 }
3051 let _ = reg.save(&state.registry_path);
3052 drop(reg);
3053
3054 if linked_count == 0 {
3055 return axum::response::Redirect::to(
3056 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3057 )
3058 .into_response();
3059 }
3060 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3061}
3062
3063#[derive(Deserialize)]
3064struct RelocateScanForm {
3065 run_id: String,
3066 folder_path: String,
3067 redirect_url: String,
3068}
3069
3070fn relocate_folder_err(
3073 want_json: bool,
3074 status: StatusCode,
3075 msg: &str,
3076 run_id: &str,
3077 folder_hint: &str,
3078 redirect_url: &str,
3079 csp_nonce: &str,
3080) -> Response {
3081 if want_json {
3082 (
3083 status,
3084 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3085 )
3086 .into_response()
3087 } else {
3088 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3089 }
3090}
3091
3092#[allow(clippy::too_many_lines)]
3093async fn relocate_scan_handler(
3094 State(state): State<AppState>,
3095 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3096 headers: axum::http::HeaderMap,
3097 Form(form): Form<RelocateScanForm>,
3098) -> impl IntoResponse {
3099 let want_json = headers
3100 .get(axum::http::header::ACCEPT)
3101 .and_then(|v| v.to_str().ok())
3102 .is_some_and(|v| v.contains("application/json"));
3103 if state.server_mode {
3104 return StatusCode::NOT_FOUND.into_response();
3105 }
3106
3107 let run_id = form.run_id.trim().to_string();
3108 let redirect_url = form.redirect_url.trim().to_string();
3109
3110 let run_exists = {
3111 let reg = state.registry.lock().await;
3112 reg.find_by_run_id(&run_id).is_some()
3113 };
3114 if !run_exists {
3115 if want_json {
3116 return (
3117 StatusCode::NOT_FOUND,
3118 axum::Json(serde_json::json!({
3119 "ok": false,
3120 "message": format!("Run ID '{run_id}' not found in registry.")
3121 })),
3122 )
3123 .into_response();
3124 }
3125 let html = ErrorTemplate {
3126 message: format!("Run ID '{run_id}' not found in registry."),
3127 last_report_url: Some("/compare-scans".to_string()),
3128 last_report_label: Some("Compare Scans".to_string()),
3129 run_id: Some(run_id.clone()),
3130 error_code: Some(404),
3131 csp_nonce: csp_nonce.clone(),
3132 version: env!("CARGO_PKG_VERSION"),
3133 }
3134 .render()
3135 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3136 return Html(html).into_response();
3137 }
3138
3139 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3140 Ok(p) => strip_unc_prefix(p),
3141 Err(_) => {
3142 return relocate_folder_err(
3143 want_json,
3144 StatusCode::UNPROCESSABLE_ENTITY,
3145 "Folder not found or path is invalid.",
3146 &run_id,
3147 form.folder_path.trim(),
3148 &redirect_url,
3149 &csp_nonce,
3150 );
3151 }
3152 };
3153 if !folder.is_dir() {
3154 return relocate_folder_err(
3155 want_json,
3156 StatusCode::UNPROCESSABLE_ENTITY,
3157 "Selected path is not a directory.",
3158 &run_id,
3159 &folder.display().to_string(),
3160 &redirect_url,
3161 &csp_nonce,
3162 );
3163 }
3164
3165 let json_candidates = find_result_files_by_ext(&folder, "json");
3166 if json_candidates.is_empty() {
3167 let msg = format!(
3168 "No result JSON files found in the selected folder.\nSearched: {}",
3169 folder.display()
3170 );
3171 return relocate_folder_err(
3172 want_json,
3173 StatusCode::UNPROCESSABLE_ENTITY,
3174 &msg,
3175 &run_id,
3176 &folder.display().to_string(),
3177 &redirect_url,
3178 &csp_nonce,
3179 );
3180 }
3181
3182 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3183 let msg = format!(
3184 "No matching scan found in the selected folder.\n\
3185 The JSON files present do not contain run ID: {run_id}\n\
3186 Searched: {}",
3187 folder.display()
3188 );
3189 return relocate_folder_err(
3190 want_json,
3191 StatusCode::UNPROCESSABLE_ENTITY,
3192 &msg,
3193 &run_id,
3194 &folder.display().to_string(),
3195 &redirect_url,
3196 &csp_nonce,
3197 );
3198 };
3199
3200 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3201 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3202 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3203
3204 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3205 redirect_url
3206 } else {
3207 "/compare-scans".to_string()
3208 };
3209 redirect_or_json_ok(want_json, &safe_redirect)
3210}
3211
3212fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3213 let mut out = Vec::new();
3214 collect_scan_files_by_ext(folder, ext, &mut out);
3215 if let Ok(rd) = fs::read_dir(folder) {
3216 for entry in rd.flatten() {
3217 let sub = entry.path();
3218 if sub.is_dir() {
3219 collect_scan_files_by_ext(&sub, ext, &mut out);
3220 }
3221 }
3222 }
3223 out
3224}
3225
3226fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3227 let Ok(rd) = fs::read_dir(dir) else { return };
3228 for entry in rd.flatten() {
3229 let p = entry.path();
3230 if p.is_file()
3231 && p.file_stem()
3232 .and_then(|n| n.to_str())
3233 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3234 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3235 {
3236 out.push(p);
3237 }
3238 }
3239}
3240
3241fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3242 candidates
3243 .iter()
3244 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3245 .cloned()
3246}
3247
3248async fn update_run_file_paths(
3249 state: &AppState,
3250 run_id: &str,
3251 json_path: PathBuf,
3252 html_path: Option<PathBuf>,
3253 pdf_path: Option<PathBuf>,
3254) {
3255 let mut reg = state.registry.lock().await;
3256 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3257 entry.json_path = Some(json_path);
3258 if let Some(hp) = html_path {
3259 entry.html_path = Some(hp);
3260 }
3261 if let Some(pp) = pdf_path {
3262 entry.pdf_path = Some(pp);
3263 }
3264 }
3265 let _ = reg.save(&state.registry_path);
3266}
3267
3268fn missing_scan_relocate_response(
3269 message: &str,
3270 run_id: &str,
3271 folder_hint: &str,
3272 redirect_url: &str,
3273 server_mode: bool,
3274 csp_nonce: &str,
3275) -> axum::response::Response {
3276 let html = RelocateScanTemplate {
3277 message: message.to_string(),
3278 run_id: run_id.to_string(),
3279 folder_hint: folder_hint.to_string(),
3280 redirect_url: redirect_url.to_string(),
3281 server_mode,
3282 csp_nonce: csp_nonce.to_owned(),
3283 version: env!("CARGO_PKG_VERSION"),
3284 }
3285 .render()
3286 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3287 (StatusCode::NOT_FOUND, Html(html)).into_response()
3288}
3289
3290fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3294 let mut candidates = Vec::new();
3295 if let Some(j) = find_result_json_in_dir(folder) {
3296 candidates.push(j);
3297 }
3298 if let Ok(dir_entries) = fs::read_dir(folder) {
3299 for entry in dir_entries.flatten() {
3300 let sub = entry.path();
3301 if sub.is_dir() {
3302 if let Some(j) = find_result_json_in_dir(&sub) {
3303 candidates.push(j);
3304 }
3305 }
3306 }
3307 }
3308 candidates
3309}
3310
3311fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3312 reg.entries.iter().any(|e| {
3313 let dir_match = e
3314 .json_path
3315 .as_ref()
3316 .and_then(|p| p.parent())
3317 .is_some_and(|p| p == parent)
3318 || e.html_path
3319 .as_ref()
3320 .and_then(|p| p.parent())
3321 .is_some_and(|p| p == parent);
3322 dir_match
3323 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3324 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3325 })
3326}
3327
3328fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3329 let parent = json_path.parent()?.to_path_buf();
3330 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3331 rd.flatten()
3332 .map(|e| e.path())
3333 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3334 });
3335 let run = read_json(&json_path).ok()?;
3336 let project_label = run.input_roots.first().map_or_else(
3337 || "Unknown Project".to_string(),
3338 |r| sanitize_project_label(r),
3339 );
3340 Some(RegistryEntry {
3341 run_id: run.tool.run_id.clone(),
3342 timestamp_utc: run.tool.timestamp_utc,
3343 project_label,
3344 input_roots: run.input_roots.clone(),
3345 json_path: Some(json_path),
3346 html_path,
3347 pdf_path: None,
3348 csv_path: None,
3349 xlsx_path: None,
3350 summary: ScanSummarySnapshot {
3351 files_analyzed: run.summary_totals.files_analyzed,
3352 files_skipped: run.summary_totals.files_skipped,
3353 total_physical_lines: run.summary_totals.total_physical_lines,
3354 code_lines: run.summary_totals.code_lines,
3355 comment_lines: run.summary_totals.comment_lines,
3356 blank_lines: run.summary_totals.blank_lines,
3357 functions: run.summary_totals.functions,
3358 classes: run.summary_totals.classes,
3359 variables: run.summary_totals.variables,
3360 imports: run.summary_totals.imports,
3361 test_count: run.summary_totals.test_count,
3362 coverage_lines_found: run.summary_totals.coverage_lines_found,
3363 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3364 coverage_functions_found: run.summary_totals.coverage_functions_found,
3365 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3366 coverage_branches_found: run.summary_totals.coverage_branches_found,
3367 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3368 },
3369 git_branch: run.git_branch.clone(),
3370 git_commit: run.git_commit_short.clone(),
3371 git_author: run.git_commit_author.clone(),
3372 git_tags: run.git_tags.clone(),
3373 git_nearest_tag: run.git_nearest_tag.clone(),
3374 git_commit_date: run.git_commit_date,
3375 })
3376}
3377
3378fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3381 let mut linked = 0usize;
3382 for json_path in collect_result_json_candidates(folder) {
3383 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3384 continue;
3385 };
3386 if is_dir_already_registered(reg, &parent) {
3387 continue;
3388 }
3389 let Some(entry) = build_registry_entry_from_json(json_path) else {
3390 continue;
3391 };
3392 reg.add_entry(entry);
3393 linked += 1;
3394 }
3395 linked
3396}
3397
3398async fn auto_scan_watched_dirs(state: &AppState) {
3400 let dirs: Vec<PathBuf> = {
3401 let wd = state.watched_dirs.lock().await;
3402 wd.dirs.clone()
3403 };
3404 if dirs.is_empty() {
3405 return;
3406 }
3407 let mut reg = state.registry.lock().await;
3408 let mut total = 0usize;
3409 for dir in &dirs {
3410 if dir.is_dir() {
3411 total += scan_folder_into_registry(dir, &mut reg);
3412 }
3413 }
3414 if total > 0 {
3415 let _ = reg.save(&state.registry_path);
3416 }
3417}
3418
3419#[derive(Deserialize)]
3422struct WatchedDirForm {
3423 folder_path: String,
3424 #[serde(default = "default_redirect")]
3425 redirect_to: String,
3426}
3427
3428fn default_redirect() -> String {
3429 "/view-reports".to_string()
3430}
3431
3432#[derive(Deserialize)]
3433struct WatchedDirRefreshForm {
3434 #[serde(default = "default_redirect")]
3435 redirect_to: String,
3436}
3437
3438fn safe_redirect(dest: &str) -> &str {
3442 if dest.starts_with('/') {
3443 dest
3444 } else {
3445 "/"
3446 }
3447}
3448
3449async fn add_watched_dir_handler(
3452 State(state): State<AppState>,
3453 Form(form): Form<WatchedDirForm>,
3454) -> impl IntoResponse {
3455 if state.server_mode {
3456 return StatusCode::NOT_FOUND.into_response();
3457 }
3458 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3459 strip_unc_prefix(p)
3460 } else {
3461 let dest = format!(
3462 "{}?error=Folder+not+found+or+path+is+invalid.",
3463 safe_redirect(&form.redirect_to)
3464 );
3465 return axum::response::Redirect::to(&dest).into_response();
3466 };
3467 if !folder.is_dir() {
3468 let dest = format!(
3469 "{}?error=Selected+path+is+not+a+directory.",
3470 safe_redirect(&form.redirect_to)
3471 );
3472 return axum::response::Redirect::to(&dest).into_response();
3473 }
3474
3475 {
3477 let mut wd = state.watched_dirs.lock().await;
3478 wd.add(folder.clone());
3479 let _ = wd.save(&state.watched_dirs_path);
3480 }
3481
3482 let linked = {
3484 let mut reg = state.registry.lock().await;
3485 let n = scan_folder_into_registry(&folder, &mut reg);
3486 if n > 0 {
3487 let _ = reg.save(&state.registry_path);
3488 }
3489 n
3490 };
3491
3492 let dest = if linked > 0 {
3493 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3494 } else {
3495 format!(
3496 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3497 safe_redirect(&form.redirect_to)
3498 )
3499 };
3500 axum::response::Redirect::to(&dest).into_response()
3501}
3502
3503async fn remove_watched_dir_handler(
3504 State(state): State<AppState>,
3505 Form(form): Form<WatchedDirForm>,
3506) -> impl IntoResponse {
3507 if state.server_mode {
3508 return StatusCode::NOT_FOUND.into_response();
3509 }
3510 let folder = PathBuf::from(&form.folder_path);
3511 {
3512 let mut wd = state.watched_dirs.lock().await;
3513 wd.remove(&folder);
3514 let _ = wd.save(&state.watched_dirs_path);
3515 }
3516 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3517}
3518
3519async fn refresh_watched_dirs_handler(
3520 State(state): State<AppState>,
3521 Form(form): Form<WatchedDirRefreshForm>,
3522) -> impl IntoResponse {
3523 if state.server_mode {
3524 return StatusCode::NOT_FOUND.into_response();
3525 }
3526 let dirs: Vec<PathBuf> = {
3527 let wd = state.watched_dirs.lock().await;
3528 wd.dirs.clone()
3529 };
3530 let mut total = 0usize;
3531 {
3532 let mut reg = state.registry.lock().await;
3533 for dir in &dirs {
3534 if dir.is_dir() {
3535 total += scan_folder_into_registry(dir, &mut reg);
3536 }
3537 }
3538 if total > 0 {
3539 let _ = reg.save(&state.registry_path);
3540 }
3541 }
3542 let dest = if total > 0 {
3543 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3544 } else {
3545 safe_redirect(&form.redirect_to).to_owned()
3546 };
3547 axum::response::Redirect::to(&dest).into_response()
3548}
3549
3550#[derive(Debug, Deserialize)]
3551struct OpenPathQuery {
3552 path: Option<String>,
3553}
3554
3555fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3556 let mut ancestor = std::path::Path::new(raw);
3557 loop {
3558 match ancestor.parent() {
3559 Some(p) => {
3560 ancestor = p;
3561 if ancestor.is_dir() {
3562 break;
3563 }
3564 }
3565 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3566 }
3567 }
3568 Ok(ancestor.to_path_buf())
3569}
3570
3571async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3572 match tokio::fs::canonicalize(raw).await {
3573 Ok(canonical) if canonical.is_file() => canonical
3574 .parent()
3575 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3576 Ok(p.to_path_buf())
3577 }),
3578 Ok(canonical) if canonical.is_dir() => Ok(canonical),
3579 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3580 Err(_) => find_existing_ancestor(raw),
3581 }
3582}
3583
3584async fn open_path_handler(
3585 State(state): State<AppState>,
3586 Query(query): Query<OpenPathQuery>,
3587) -> impl IntoResponse {
3588 if state.server_mode {
3589 return Json(serde_json::json!({
3590 "server_mode_disabled": true,
3591 "message": "Opening a path in the file manager is only available in local desktop mode."
3592 }))
3593 .into_response();
3594 }
3595 if std::env::var("SLOC_HEADLESS").is_ok() {
3597 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3598 }
3599 let raw = match query.path.as_deref() {
3600 Some(p) if !p.is_empty() => p,
3601 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3602 };
3603
3604 let target = match resolve_open_target(raw).await {
3608 Ok(p) => p,
3609 Err((code, msg)) => return (code, msg).into_response(),
3610 };
3611
3612 #[cfg(target_os = "windows")]
3613 win_dialog_focus::open_folder_foreground(target);
3614 #[cfg(target_os = "macos")]
3615 let _ = std::process::Command::new("open")
3616 .arg(&target)
3617 .stdout(Stdio::null())
3618 .stderr(Stdio::null())
3619 .spawn();
3620 #[cfg(target_os = "linux")]
3621 {
3622 let folder_name = target
3623 .file_name()
3624 .and_then(|n| n.to_str())
3625 .map(str::to_owned);
3626 let _ = std::process::Command::new("xdg-open")
3627 .arg(&target)
3628 .stdout(Stdio::null())
3629 .stderr(Stdio::null())
3630 .spawn();
3631 if let Some(name) = folder_name {
3635 std::thread::spawn(move || {
3636 std::thread::sleep(std::time::Duration::from_millis(800));
3637 let _ = std::process::Command::new("wmctrl")
3638 .args(["-a", &name])
3639 .stdout(Stdio::null())
3640 .stderr(Stdio::null())
3641 .spawn();
3642 });
3643 }
3644 }
3645
3646 Json(serde_json::json!({"ok": true})).into_response()
3647}
3648
3649async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3650 let (content_type, bytes): (&'static str, &'static [u8]) =
3651 match (folder.as_str(), file.as_str()) {
3652 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3653 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3654 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3655 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3656 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3657 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3658 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3659 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3660 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3661 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3662 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3663 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3664 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3665 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3666 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3667 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3668 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3669 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3670 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3671 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3672 _ => return StatusCode::NOT_FOUND.into_response(),
3673 };
3674 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3675}
3676
3677async fn preview_handler(
3678 State(state): State<AppState>,
3679 Query(query): Query<PreviewQuery>,
3680) -> impl IntoResponse {
3681 let raw_path = query
3682 .path
3683 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3684 let resolved = resolve_input_path(&raw_path);
3685
3686 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3690 return Html(
3691 r#"<div class="preview-error">Sample directory not available on this server.
3692 Enter a path to a project directory or upload files using Browse.</div>"#
3693 .to_string(),
3694 );
3695 }
3696
3697 if state.server_mode {
3698 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3699 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3701 let config = &state.base_config;
3702 if config.discovery.allowed_scan_roots.is_empty() {
3703 return Html(
3704 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3705 );
3706 }
3707 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3708 fs::canonicalize(root)
3709 .ok()
3710 .is_some_and(|r| canonical.starts_with(&r))
3711 });
3712 if !allowed {
3713 return Html(
3714 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3715 );
3716 }
3717 }
3718 }
3719
3720 let include_patterns = split_patterns(query.include_globs.as_deref());
3721 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3722
3723 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3724 Ok(html) => Html(html),
3725 Err(err) => Html(format!(
3726 r#"<div class="preview-error">Preview failed: {}</div>"#,
3727 escape_html(&err.to_string())
3728 )),
3729 }
3730}
3731
3732#[derive(Debug, Deserialize, Default)]
3733struct SuggestCoverageQuery {
3734 path: Option<String>,
3735}
3736
3737#[derive(Serialize)]
3738struct SuggestCoverageResponse {
3739 found: Option<String>,
3740 tool: Option<&'static str>,
3741 hint: Option<&'static str>,
3742}
3743
3744async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3745 const CANDIDATES: &[&str] = &[
3746 "coverage/lcov.info",
3748 "lcov.info",
3749 "target/llvm-cov/lcov.info",
3750 "target/coverage/lcov.info",
3751 "target/debug/coverage/lcov.info",
3752 "coverage/coverage.lcov",
3753 "build/coverage/lcov.info",
3754 "reports/lcov.info",
3755 "coverage.xml",
3757 "coverage/coverage.xml",
3758 "target/site/cobertura/coverage.xml",
3759 "build/reports/coverage/coverage.xml",
3760 "target/site/jacoco/jacoco.xml",
3762 "build/reports/jacoco/test/jacocoTestReport.xml",
3763 "build/reports/jacoco/jacocoTestReport.xml",
3764 "build/jacoco/jacoco.xml",
3765 ];
3766 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3767 let found = CANDIDATES
3768 .iter()
3769 .map(|rel| root.join(rel))
3770 .find(|p| p.is_file())
3771 .map(|p| display_path(&p));
3772
3773 let (tool, hint) = detect_coverage_tool(&root);
3774 Json(SuggestCoverageResponse { found, tool, hint })
3775}
3776
3777fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3780 if root.join("Cargo.toml").is_file() {
3781 return (
3782 Some("cargo-llvm-cov"),
3783 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3784 );
3785 }
3786 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3787 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3788 }
3789 if root.join("pom.xml").is_file() {
3790 return (Some("jacoco"), Some("mvn test jacoco:report"));
3791 }
3792 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3793 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3794 }
3795 (None, None)
3796}
3797
3798#[allow(clippy::result_large_err)]
3800fn validate_server_scan_path(
3801 config: &sloc_config::AppConfig,
3802 resolved_path: &Path,
3803 csp_nonce: &str,
3804) -> Result<(), Response> {
3805 if config.discovery.allowed_scan_roots.is_empty() {
3806 let template = ErrorTemplate {
3807 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3808 Set allowed_scan_roots in the server config to permit scanning."
3809 .to_string(),
3810 last_report_url: None,
3811 last_report_label: None,
3812 run_id: None,
3813 error_code: Some(403),
3814 csp_nonce: csp_nonce.to_owned(),
3815 version: env!("CARGO_PKG_VERSION"),
3816 };
3817 return Err((
3818 StatusCode::FORBIDDEN,
3819 Html(
3820 template
3821 .render()
3822 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3823 ),
3824 )
3825 .into_response());
3826 }
3827 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3828 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3829 fs::canonicalize(root)
3830 .ok()
3831 .is_some_and(|r| canonical.starts_with(&r))
3832 });
3833 if !allowed {
3834 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3835 "Scan path not in allowed_scan_roots");
3836 let template = ErrorTemplate {
3837 message: "The requested path is not within an allowed scan directory.".to_string(),
3838 last_report_url: None,
3839 last_report_label: None,
3840 run_id: None,
3841 error_code: Some(403),
3842 csp_nonce: csp_nonce.to_owned(),
3843 version: env!("CARGO_PKG_VERSION"),
3844 };
3845 return Err((
3846 StatusCode::FORBIDDEN,
3847 Html(
3848 template
3849 .render()
3850 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3851 ),
3852 )
3853 .into_response());
3854 }
3855 Ok(())
3856}
3857
3858fn apply_output_dir_exclusions(
3860 config: &mut sloc_config::AppConfig,
3861 project_path: &str,
3862 raw_output_dir: &str,
3863) {
3864 let project_root = resolve_input_path(project_path);
3865 let raw_out = raw_output_dir.trim();
3866 let resolved_out = if raw_out.is_empty() {
3867 project_root.join("sloc")
3868 } else if Path::new(raw_out).is_absolute() {
3869 PathBuf::from(raw_out)
3870 } else {
3871 workspace_root().join(raw_out)
3872 };
3873 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3874 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3875 let dir = first.to_string();
3876 if !config.discovery.excluded_directories.contains(&dir) {
3877 config.discovery.excluded_directories.push(dir);
3878 }
3879 }
3880 }
3881 if !config
3882 .discovery
3883 .excluded_directories
3884 .iter()
3885 .any(|d| d == "sloc")
3886 {
3887 config
3888 .discovery
3889 .excluded_directories
3890 .push("sloc".to_string());
3891 }
3892}
3893
3894const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3896 ScanSummarySnapshot {
3897 files_analyzed: run.summary_totals.files_analyzed,
3898 files_skipped: run.summary_totals.files_skipped,
3899 total_physical_lines: run.summary_totals.total_physical_lines,
3900 code_lines: run.summary_totals.code_lines,
3901 comment_lines: run.summary_totals.comment_lines,
3902 blank_lines: run.summary_totals.blank_lines,
3903 functions: run.summary_totals.functions,
3904 classes: run.summary_totals.classes,
3905 variables: run.summary_totals.variables,
3906 imports: run.summary_totals.imports,
3907 test_count: run.summary_totals.test_count,
3908 coverage_lines_found: run.summary_totals.coverage_lines_found,
3909 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3910 coverage_functions_found: run.summary_totals.coverage_functions_found,
3911 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3912 coverage_branches_found: run.summary_totals.coverage_branches_found,
3913 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3914 }
3915}
3916
3917pub(crate) fn build_run_registry_entry(
3919 run: &AnalysisRun,
3920 run_id: &str,
3921 project_label: &str,
3922 artifacts: &RunArtifacts,
3923) -> RegistryEntry {
3924 RegistryEntry {
3925 run_id: run_id.to_owned(),
3926 timestamp_utc: run.tool.timestamp_utc,
3927 project_label: project_label.to_owned(),
3928 input_roots: run.input_roots.clone(),
3929 json_path: artifacts.json_path.clone(),
3930 html_path: artifacts.html_path.clone(),
3931 pdf_path: artifacts.pdf_path.clone(),
3932 csv_path: artifacts.csv_path.clone(),
3933 xlsx_path: artifacts.xlsx_path.clone(),
3934 summary: summary_snapshot_from_run(run),
3935 git_branch: run.git_branch.clone(),
3936 git_commit: run.git_commit_short.clone(),
3937 git_author: run.git_commit_author.clone(),
3938 git_tags: run.git_tags.clone(),
3939 git_nearest_tag: run.git_nearest_tag.clone(),
3940 git_commit_date: run.git_commit_date.clone(),
3941 }
3942}
3943
3944fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3946 if let Some(policy) = form.mixed_line_policy {
3947 config.analysis.mixed_line_policy = policy;
3948 }
3949 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3950 config.analysis.generated_file_detection =
3951 form.generated_file_detection.as_deref() != Some("disabled");
3952 config.analysis.minified_file_detection =
3953 form.minified_file_detection.as_deref() != Some("disabled");
3954 config.analysis.vendor_directory_detection =
3955 form.vendor_directory_detection.as_deref() != Some("disabled");
3956 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3957 if let Some(binary_behavior) = form.binary_file_behavior {
3958 config.analysis.binary_file_behavior = binary_behavior;
3959 }
3960 apply_report_opts(config, form);
3961 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
3962 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
3963 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
3964 if let Some(policy) = form.continuation_line_policy {
3965 config.analysis.continuation_line_policy = policy;
3966 }
3967 if let Some(policy) = form.blank_in_block_comment_policy {
3968 config.analysis.blank_in_block_comment_policy = policy;
3969 }
3970 config.analysis.count_compiler_directives =
3971 form.count_compiler_directives.as_deref() != Some("disabled");
3972 apply_style_threshold(config, form);
3973 apply_coverage_path(config, form);
3974}
3975
3976fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3977 if let Some(report_title) = form.report_title.as_deref() {
3978 let trimmed = report_title.trim();
3979 if !trimmed.is_empty() {
3980 config.reporting.report_title = trimmed.to_string();
3981 }
3982 }
3983 if let Some(hf) = form.report_header_footer.as_deref() {
3984 let trimmed = hf.trim();
3985 config.reporting.report_header_footer = if trimmed.is_empty() {
3986 None
3987 } else {
3988 Some(trimmed.to_string())
3989 };
3990 }
3991}
3992
3993fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3994 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
3995 if let Ok(t) = threshold_str.parse::<u16>() {
3996 if t == 80 || t == 100 || t == 120 {
3997 config.analysis.style_col_threshold = t;
3998 }
3999 }
4000 }
4001 if let Some(v) = form.style_analysis_enabled.as_deref() {
4002 config.analysis.style_analysis_enabled = v != "disabled";
4003 }
4004 if let Some(v) = form.style_score_threshold.as_deref() {
4005 if let Ok(t) = v.parse::<u8>() {
4006 config.analysis.style_score_threshold = t.min(100);
4007 }
4008 }
4009 if let Some(v) = form.style_lang_scope.as_deref() {
4010 let scope = v.trim();
4011 if scope == "c_family" || scope == "all" {
4012 config.analysis.style_lang_scope = scope.to_string();
4013 }
4014 }
4015}
4016
4017fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4018 if let Some(cov) = &form.coverage_file {
4019 let trimmed = cov.trim();
4020 if !trimmed.is_empty() {
4021 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4022 }
4023 }
4024}
4025
4026fn spawn_pdf_background(
4030 pending_pdf: PendingPdf,
4031 run_id: String,
4032 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4033) {
4034 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4035 tokio::spawn(async move {
4036 let result = tokio::task::spawn_blocking(move || {
4037 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4038 if cleanup_src {
4039 let _ = fs::remove_file(&pdf_src);
4040 }
4041 r
4042 })
4043 .await;
4044 let failed = match result {
4045 Ok(Ok(())) => false,
4046 Ok(Err(err)) => {
4047 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4048 true
4049 }
4050 Err(err) => {
4051 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4052 true
4053 }
4054 };
4055 if failed {
4056 let mut map = artifacts.lock().await;
4057 if let Some(entry) = map.get_mut(&run_id) {
4058 entry.pdf_path = None;
4059 }
4060 }
4061 });
4062 }
4063}
4064
4065fn spawn_native_pdf_background(
4069 json_path: PathBuf,
4070 pdf_dest: PathBuf,
4071 run_id: String,
4072 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4073) {
4074 tokio::spawn(async move {
4075 let result = tokio::task::spawn_blocking(move || {
4076 let run = sloc_core::read_json(&json_path)?;
4077 write_pdf_from_run(&run, &pdf_dest)
4078 })
4079 .await;
4080 let failed = match result {
4081 Ok(Ok(())) => false,
4082 Ok(Err(err)) => {
4083 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4084 true
4085 }
4086 Err(err) => {
4087 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4088 true
4089 }
4090 };
4091 if failed {
4092 let mut map = artifacts.lock().await;
4093 if let Some(entry) = map.get_mut(&run_id) {
4094 entry.pdf_path = None;
4095 }
4096 }
4097 });
4098}
4099
4100fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4102 cmp.file_deltas
4103 .iter()
4104 .map(|f| match f.status {
4105 FileChangeStatus::Added => f.current_code,
4106 FileChangeStatus::Modified => f.code_delta.max(0),
4107 _ => 0,
4108 })
4109 .sum()
4110}
4111
4112fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4114 cmp.file_deltas
4115 .iter()
4116 .map(|f| match f.status {
4117 FileChangeStatus::Removed => f.baseline_code,
4118 FileChangeStatus::Modified => (-f.code_delta).max(0),
4119 _ => 0,
4120 })
4121 .sum()
4122}
4123
4124fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4126 cmp.file_deltas
4127 .iter()
4128 .filter(|f| f.status == FileChangeStatus::Unchanged)
4129 .map(|f| f.current_code)
4130 .sum()
4131}
4132
4133fn build_submodule_row(
4135 s: &sloc_core::SubmoduleSummary,
4136 run: &AnalysisRun,
4137 run_id: &str,
4138 run_dir: &Path,
4139) -> SubmoduleRow {
4140 let safe = sanitize_project_label(&s.name);
4141 let artifact_key = format!("sub_{safe}");
4142 let pdf_artifact_key = format!("sub_{safe}_pdf");
4143 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4144 let parent_path = run
4145 .input_roots
4146 .first()
4147 .map_or("", std::string::String::as_str);
4148 let sub_run = build_sub_run(run, s, parent_path);
4149 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4150 render_sub_report_html(&sub_run, Some(&pdf_server_url))
4151 .ok()
4152 .and_then(|sub_html| {
4153 let sub_dir = run_dir.join("submodules");
4154 let _ = fs::create_dir_all(&sub_dir);
4155 let html_path = sub_dir.join(format!("{artifact_key}.html"));
4156 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4157 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4160 let _ = write_pdf_from_run(&sub_run, &pdf_path);
4161 Some(format!("/runs/{artifact_key}/{run_id}"))
4162 } else {
4163 None
4164 }
4165 })
4166 } else {
4167 None
4168 };
4169 SubmoduleRow {
4170 name: s.name.clone(),
4171 relative_path: s.relative_path.clone(),
4172 files_analyzed: s.files_analyzed,
4173 code_lines: s.code_lines,
4174 comment_lines: s.comment_lines,
4175 blank_lines: s.blank_lines,
4176 total_physical_lines: s.total_physical_lines,
4177 html_url,
4178 }
4179}
4180
4181#[allow(clippy::similar_names)]
4184#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4186async fn analyze_handler(
4187 State(state): State<AppState>,
4188 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4189 Form(form): Form<AnalyzeForm>,
4190) -> impl IntoResponse {
4191 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4192 let template = ErrorTemplate {
4193 message: format!(
4194 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4195 Please wait a moment and try again."
4196 ),
4197 last_report_url: None,
4198 last_report_label: None,
4199 run_id: None,
4200 error_code: Some(503),
4201 csp_nonce: csp_nonce.clone(),
4202 version: env!("CARGO_PKG_VERSION"),
4203 };
4204 return (
4205 StatusCode::SERVICE_UNAVAILABLE,
4206 Html(
4207 template
4208 .render()
4209 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4210 ),
4211 )
4212 .into_response();
4213 };
4214
4215 let mut config = state.base_config.clone();
4216
4217 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4218 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4219 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4220
4221 if !is_git_mode {
4222 let resolved_path = resolve_input_path(&form.path);
4223 if state.server_mode
4224 && !is_upload_tmp_path(&resolved_path)
4225 && !is_sample_path(&resolved_path)
4226 {
4227 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4228 return resp;
4229 }
4230 }
4231 config.discovery.root_paths = vec![resolved_path];
4232 }
4233
4234 apply_form_to_config(&mut config, &form);
4235 apply_output_dir_exclusions(
4236 &mut config,
4237 &form.path,
4238 form.output_dir.as_deref().unwrap_or(""),
4239 );
4240
4241 let wait_id = uuid::Uuid::new_v4().to_string();
4243 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4244
4245 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4247 let task_cancel = Arc::clone(&cancel_token);
4248
4249 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4251 let task_phase = Arc::clone(&phase);
4252
4253 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4254 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4255 let task_files_done = Arc::clone(&files_done);
4256 let task_files_total = Arc::clone(&files_total);
4257
4258 {
4261 let mut runs = state.async_runs.lock().await;
4262 runs.insert(
4263 wait_id.clone(),
4264 AsyncRunState::Running {
4265 started_at: std::time::Instant::now(),
4266 cancel_token,
4267 phase,
4268 files_done,
4269 files_total,
4270 },
4271 );
4272 }
4273
4274 let task = AnalysisTask {
4275 sem_permit,
4276 state: state.clone(),
4277 wait_id: wait_id.clone(),
4278 config,
4279 cancel: task_cancel,
4280 phase: task_phase,
4281 files_done: task_files_done,
4282 files_total: task_files_total,
4283 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4284 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4285 project_path: form.path.clone(),
4286 output_dir: if state.server_mode {
4290 None
4291 } else {
4292 form.output_dir.clone()
4293 },
4294 clones_dir: state.git_clones_dir.clone(),
4295 cocomo_mode: form
4296 .cocomo_mode
4297 .clone()
4298 .unwrap_or_else(|| "organic".to_string()),
4299 complexity_alert: form
4300 .complexity_alert
4301 .as_deref()
4302 .and_then(|s| s.parse::<u32>().ok())
4303 .unwrap_or(0),
4304 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4305 };
4306
4307 tokio::spawn(run_analysis_task(task));
4308
4309 let template = ScanWaitTemplate {
4310 version: env!("CARGO_PKG_VERSION"),
4311 wait_id_json,
4312 project_path: form.path.clone(),
4313 csp_nonce,
4314 };
4315 let html = template
4316 .render()
4317 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4318 let mut response = Html(html).into_response();
4319 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4320 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4321 response.headers_mut().insert(name, val);
4322 }
4323 }
4324 response
4325}
4326
4327struct AnalysisTask {
4328 sem_permit: tokio::sync::OwnedSemaphorePermit,
4329 state: AppState,
4330 wait_id: String,
4331 config: AppConfig,
4332 cancel: Arc<std::sync::atomic::AtomicBool>,
4333 phase: Arc<std::sync::Mutex<String>>,
4334 files_done: Arc<std::sync::atomic::AtomicUsize>,
4335 files_total: Arc<std::sync::atomic::AtomicUsize>,
4336 git_repo: Option<String>,
4337 git_ref: Option<String>,
4338 project_path: String,
4339 output_dir: Option<String>,
4340 clones_dir: PathBuf,
4341 cocomo_mode: String,
4342 complexity_alert: u32,
4343 exclude_duplicates: bool,
4344}
4345
4346#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4348 let _permit = task.sem_permit;
4349
4350 let cancel_sb = Arc::clone(&task.cancel);
4351 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4352 let clones_dir_sb = task.clones_dir;
4353 let upload_staging_root = task
4355 .config
4356 .discovery
4357 .root_paths
4358 .first()
4359 .filter(|p| is_upload_tmp_path(p))
4360 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4361 .map(PathBuf::from);
4362 let config_sb = task.config;
4363 let progress_sb = sloc_core::ProgressCounters {
4364 files_done: Arc::clone(&task.files_done),
4365 files_total: Arc::clone(&task.files_total),
4366 };
4367 if let Ok(mut p) = task.phase.lock() {
4368 *p = "Scanning files".to_string();
4369 }
4370 let analysis_result = tokio::task::spawn_blocking(move || {
4371 run_analysis_blocking(
4372 config_sb,
4373 git_repo_sb,
4374 git_ref_sb,
4375 clones_dir_sb,
4376 cancel_sb,
4377 Some(progress_sb),
4378 )
4379 })
4380 .await
4381 .map_err(|err| anyhow::anyhow!(err.to_string()))
4382 .and_then(|result| result);
4383
4384 if let Ok(mut p) = task.phase.lock() {
4385 *p = "Writing reports".to_string();
4386 }
4387
4388 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4390 let mut runs = task.state.async_runs.lock().await;
4391 if matches!(
4393 runs.get(&task.wait_id),
4394 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4395 ) {
4396 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4397 }
4398 drop(runs);
4399 return;
4400 }
4401
4402 let run = match analysis_result {
4403 Ok(v) => v,
4404 Err(err) => {
4405 if err.to_string().contains("analysis cancelled") {
4407 let mut runs = task.state.async_runs.lock().await;
4408 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4409 drop(runs);
4410 return;
4411 }
4412 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4413 let mut runs = task.state.async_runs.lock().await;
4414 runs.insert(
4415 task.wait_id.clone(),
4416 AsyncRunState::Failed {
4417 message: "Analysis failed. Check that the path exists and is readable."
4418 .to_string(),
4419 },
4420 );
4421 drop(runs);
4422 return;
4423 }
4424 };
4425
4426 let run_id = run.tool.run_id.clone();
4427 tracing::info!(event = "scan_complete", run_id = %run_id,
4428 path = %task.project_path, files = run.summary_totals.files_analyzed,
4429 "Analysis finished");
4430
4431 let prev_entry: Option<RegistryEntry> = {
4432 let reg = task.state.registry.lock().await;
4433 reg.entries_for_roots(&run.input_roots)
4434 .into_iter()
4435 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4436 .cloned()
4437 };
4438
4439 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4440 prev.json_path
4441 .as_ref()
4442 .and_then(|p| read_json(p).ok())
4443 .map(|prev_run| compute_delta(&prev_run, &run))
4444 });
4445 let prev_scan_count: usize = {
4446 let reg = task.state.registry.lock().await;
4447 reg.entries_for_roots(&run.input_roots)
4448 .iter()
4449 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4450 .count()
4451 };
4452
4453 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4456 .as_ref()
4457 .zip(prev_entry.as_ref())
4458 .map(|(cmp, prev)| ReportDeltaContext {
4459 delta_code_added: sum_added_code_lines(cmp),
4460 delta_code_removed: sum_removed_code_lines(cmp),
4461 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4462 delta_files_added: cmp.files_added,
4463 delta_files_removed: cmp.files_removed,
4464 delta_files_modified: cmp.files_modified,
4465 delta_files_unchanged: cmp.files_unchanged,
4466 prev_code_lines: prev.summary.code_lines,
4467 prev_scan_count: prev_scan_count + 1,
4468 prev_scan_label: fmt_la_time(prev.timestamp_utc),
4469 prev_run_id: Some(prev.run_id.clone()),
4470 current_run_id: Some(run_id.clone()),
4471 });
4472 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4473 Ok(h) => h,
4474 Err(err) => {
4475 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4476 let mut runs = task.state.async_runs.lock().await;
4477 runs.insert(
4478 task.wait_id.clone(),
4479 AsyncRunState::Failed {
4480 message: "Failed to render HTML report.".to_string(),
4481 },
4482 );
4483 drop(runs);
4484 return;
4485 }
4486 };
4487
4488 let output_root = resolve_output_root(task.output_dir.as_deref());
4489 let project_label = derive_project_label(
4490 task.git_repo.as_deref(),
4491 task.git_ref.as_deref(),
4492 &task.project_path,
4493 );
4494 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4495 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4496
4497 let result_context = RunResultContext {
4498 prev_entry: prev_entry.clone(),
4499 prev_scan_count,
4500 project_path: task.project_path.clone(),
4501 cocomo_mode: task.cocomo_mode.clone(),
4502 complexity_alert: task.complexity_alert,
4503 exclude_duplicates: task.exclude_duplicates,
4504 };
4505
4506 let artifact_result = persist_run_artifacts(
4507 &run,
4508 &report_html,
4509 &run_dir,
4510 &run.effective_configuration.reporting.report_title,
4511 &file_stem,
4512 result_context,
4513 );
4514
4515 let (artifacts, pending_pdf) = match artifact_result {
4516 Ok(v) => v,
4517 Err(err) => {
4518 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4519 let mut runs = task.state.async_runs.lock().await;
4520 runs.insert(
4521 task.wait_id.clone(),
4522 AsyncRunState::Failed {
4523 message: "Failed to save report artifacts. Check available disk space."
4524 .to_string(),
4525 },
4526 );
4527 drop(runs);
4528 return;
4529 }
4530 };
4531
4532 {
4533 let mut map = task.state.artifacts.lock().await;
4534 map.insert(run_id.clone(), artifacts.clone());
4535 }
4536
4537 {
4538 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4539 let mut reg = task.state.registry.lock().await;
4540 reg.add_entry(entry);
4541 let _ = reg.save(&task.state.registry_path);
4542 }
4543
4544 if let Some(ref cfg_path) = artifacts.scan_config_path {
4545 save_scan_config_json(
4546 cfg_path,
4547 &run,
4548 &task.project_path,
4549 task.output_dir.as_deref(),
4550 );
4551 }
4552
4553 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4554
4555 prom_runs_total().inc();
4556
4557 let mut runs = task.state.async_runs.lock().await;
4559 runs.insert(
4560 task.wait_id.clone(),
4561 AsyncRunState::Complete {
4562 run_id: run_id.clone(),
4563 },
4564 );
4565 drop(runs);
4566
4567 if let Some(staging) = upload_staging_root {
4570 let _ = tokio::fs::remove_dir_all(staging).await;
4571 }
4572
4573 let _ = scan_delta;
4574}
4575
4576fn save_scan_config_json(
4577 cfg_path: &std::path::Path,
4578 run: &sloc_core::AnalysisRun,
4579 project_path: &str,
4580 output_dir: Option<&str>,
4581) {
4582 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4583 .ok()
4584 .and_then(|v| v.as_str().map(String::from))
4585 .unwrap_or_else(|| "code_only".to_string());
4586 let behavior_str =
4587 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4588 .ok()
4589 .and_then(|v| v.as_str().map(String::from))
4590 .unwrap_or_else(|| "skip".to_string());
4591 let scan_cfg = ScanConfig {
4592 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4593 path: project_path.to_string(),
4594 include_globs: run
4595 .effective_configuration
4596 .discovery
4597 .include_globs
4598 .join("\n"),
4599 exclude_globs: run
4600 .effective_configuration
4601 .discovery
4602 .exclude_globs
4603 .join("\n"),
4604 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4605 mixed_line_policy: policy_str,
4606 python_docstrings_as_comments: run
4607 .effective_configuration
4608 .analysis
4609 .python_docstrings_as_comments,
4610 generated_file_detection: run
4611 .effective_configuration
4612 .analysis
4613 .generated_file_detection,
4614 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4615 vendor_directory_detection: run
4616 .effective_configuration
4617 .analysis
4618 .vendor_directory_detection,
4619 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4620 binary_file_behavior: behavior_str,
4621 output_dir: output_dir.unwrap_or("").to_string(),
4622 report_title: run.effective_configuration.reporting.report_title.clone(),
4623 };
4624 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4625 let _ = std::fs::write(cfg_path, json);
4626 }
4627}
4628
4629#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
4631 mut config: AppConfig,
4632 git_repo: Option<String>,
4633 git_ref: Option<String>,
4634 clones_dir: PathBuf,
4635 cancel: Arc<std::sync::atomic::AtomicBool>,
4636 progress: Option<sloc_core::ProgressCounters>,
4637) -> Result<sloc_core::AnalysisRun> {
4638 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4639 let dest = git_clone_dest(&repo, &clones_dir);
4640 sloc_git::clone_or_fetch(&repo, &dest)?;
4641 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4642 sloc_git::create_worktree(&dest, &refname, &wt)?;
4643 config.discovery.root_paths = vec![wt.clone()];
4644 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4645 let _ = sloc_git::destroy_worktree(&dest, &wt);
4646 let mut run = run?;
4647 if run.git_branch.is_none() {
4648 run.git_branch = Some(refname);
4649 }
4650 return Ok(run);
4651 }
4652 analyze(&config, "serve", Some(&cancel), progress.as_ref())
4653}
4654
4655fn derive_project_label(
4656 git_repo: Option<&str>,
4657 git_ref: Option<&str>,
4658 fallback_path: &str,
4659) -> String {
4660 match (
4661 git_repo.filter(|s| !s.is_empty()),
4662 git_ref.filter(|s| !s.is_empty()),
4663 ) {
4664 (Some(repo), Some(refname)) => {
4665 let repo_name = repo
4666 .trim_end_matches('/')
4667 .trim_end_matches(".git")
4668 .rsplit('/')
4669 .next()
4670 .unwrap_or("repo");
4671 sanitize_project_label(&format!("{repo_name}_{refname}"))
4672 }
4673 _ => sanitize_project_label(fallback_path),
4674 }
4675}
4676
4677fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4678 let commit = commit_short.unwrap_or("").trim();
4679 if commit.is_empty() {
4680 project_label.to_string()
4681 } else {
4682 format!("{project_label}_{commit}")
4683 }
4684}
4685
4686#[derive(Serialize)]
4689#[serde(tag = "state", rename_all = "snake_case")]
4690enum AsyncRunStatusResponse {
4691 Running {
4692 elapsed_secs: u64,
4693 phase: String,
4694 files_done: u64,
4695 files_total: u64,
4696 },
4697 Complete {
4698 run_id: String,
4699 },
4700 Failed {
4701 message: String,
4702 },
4703 Cancelled,
4704}
4705
4706async fn async_run_status_handler(
4707 State(state): State<AppState>,
4708 AxumPath(wait_id): AxumPath<String>,
4709) -> Response {
4710 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4712 return error::bad_request("invalid wait_id");
4713 }
4714 let run_state = {
4715 let runs = state.async_runs.lock().await;
4716 runs.get(&wait_id).cloned()
4717 };
4718 match run_state {
4719 None => error::not_found("run not found"),
4720 Some(AsyncRunState::Running {
4721 started_at,
4722 phase,
4723 files_done,
4724 files_total,
4725 ..
4726 }) => {
4727 if started_at.elapsed() > std::time::Duration::from_hours(2) {
4729 let mut runs = state.async_runs.lock().await;
4730 runs.insert(
4731 wait_id,
4732 AsyncRunState::Failed {
4733 message: "Analysis timed out after 2 hours.".to_string(),
4734 },
4735 );
4736 drop(runs);
4737 return Json(AsyncRunStatusResponse::Failed {
4738 message: "Analysis timed out after 2 hours.".to_string(),
4739 })
4740 .into_response();
4741 }
4742 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4743 Json(AsyncRunStatusResponse::Running {
4744 elapsed_secs: started_at.elapsed().as_secs(),
4745 phase: phase_str,
4746 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4747 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4748 })
4749 .into_response()
4750 }
4751 Some(AsyncRunState::Complete { run_id }) => {
4752 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4753 }
4754 Some(AsyncRunState::Failed { message }) => {
4755 Json(AsyncRunStatusResponse::Failed { message }).into_response()
4756 }
4757 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4758 }
4759}
4760
4761async fn cancel_run_handler(
4762 State(state): State<AppState>,
4763 AxumPath(wait_id): AxumPath<String>,
4764) -> Response {
4765 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4766 return error::bad_request("invalid wait_id");
4767 }
4768 let mut runs = state.async_runs.lock().await;
4769 let resp = match runs.get(&wait_id) {
4770 Some(AsyncRunState::Running { cancel_token, .. }) => {
4771 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4772 runs.insert(wait_id, AsyncRunState::Cancelled);
4773 StatusCode::OK.into_response()
4774 }
4775 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4776 _ => error::not_found("run not found"),
4777 };
4778 drop(runs);
4779 resp
4780}
4781
4782async fn async_run_result_handler(
4783 State(state): State<AppState>,
4784 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4785 AxumPath(run_id): AxumPath<String>,
4786) -> Response {
4787 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4788 return StatusCode::BAD_REQUEST.into_response();
4789 }
4790
4791 let artifacts = {
4792 let map = state.artifacts.lock().await;
4793 map.get(&run_id).cloned()
4794 };
4795 let artifacts = if let Some(a) = artifacts {
4796 a
4797 } else {
4798 let reg = state.registry.lock().await;
4799 if let Some(entry) = reg.find_by_run_id(&run_id) {
4800 recover_artifacts_from_registry(entry)
4801 } else {
4802 let html = ErrorTemplate {
4803 message: format!(
4804 "Report not found. Run ID {} is not in the scan history.",
4805 &run_id[..run_id.len().min(8)]
4806 ),
4807 last_report_url: Some("/view-reports".to_string()),
4808 last_report_label: Some("View Reports".to_string()),
4809 run_id: Some(run_id.clone()),
4810 error_code: Some(404),
4811 csp_nonce: csp_nonce.clone(),
4812 version: env!("CARGO_PKG_VERSION"),
4813 }
4814 .render()
4815 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4816 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4817 }
4818 };
4819
4820 let json_path = if let Some(p) = &artifacts.json_path {
4821 p.clone()
4822 } else {
4823 let html = ErrorTemplate {
4824 message: "JSON result was not saved for this run.".to_string(),
4825 last_report_url: Some("/view-reports".to_string()),
4826 last_report_label: Some("View Reports".to_string()),
4827 run_id: Some(run_id.clone()),
4828 error_code: Some(404),
4829 csp_nonce: csp_nonce.clone(),
4830 version: env!("CARGO_PKG_VERSION"),
4831 }
4832 .render()
4833 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4834 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4835 };
4836
4837 let Ok(run) = read_json(&json_path) else {
4838 let folder_hint = json_path
4839 .parent()
4840 .map(|p| p.display().to_string())
4841 .unwrap_or_default();
4842 let redirect_url = format!("/runs/result/{run_id}");
4843 return missing_scan_relocate_response(
4844 &format!(
4845 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4846 deleted. Browse to the folder containing your scan output to reconnect it.",
4847 json_path.display()
4848 ),
4849 &run_id,
4850 &folder_hint,
4851 &redirect_url,
4852 state.server_mode,
4853 &csp_nonce,
4854 );
4855 };
4856
4857 let confluence_configured = {
4858 let store = state.confluence.lock().await;
4859 store.is_configured()
4860 };
4861
4862 render_result_page(
4863 &run,
4864 &artifacts,
4865 &run_id,
4866 &csp_nonce,
4867 confluence_configured,
4868 state.server_mode,
4869 )
4870}
4871
4872#[allow(clippy::too_many_lines)]
4873#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
4876 run: &AnalysisRun,
4877 artifacts: &RunArtifacts,
4878 run_id: &str,
4879 csp_nonce: &str,
4880 confluence_configured: bool,
4881 server_mode: bool,
4882) -> Response {
4883 let ctx = &artifacts.result_context;
4884 let prev_entry = &ctx.prev_entry;
4885 let prev_scan_count = ctx.prev_scan_count;
4886 let project_path = &ctx.project_path;
4887
4888 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4889 prev.json_path
4890 .as_ref()
4891 .and_then(|p| read_json(p).ok())
4892 .map(|prev_run| compute_delta(&prev_run, run))
4893 });
4894
4895 let files_analyzed = run.per_file_records.len() as u64;
4896 let files_skipped = run.skipped_file_records.len() as u64;
4897 let physical_lines = run
4898 .totals_by_language
4899 .iter()
4900 .map(|r| r.total_physical_lines)
4901 .sum::<u64>();
4902 let code_lines = run
4903 .totals_by_language
4904 .iter()
4905 .map(|r| r.code_lines)
4906 .sum::<u64>();
4907 let comment_lines = run
4908 .totals_by_language
4909 .iter()
4910 .map(|r| r.comment_lines)
4911 .sum::<u64>();
4912 let blank_lines = run
4913 .totals_by_language
4914 .iter()
4915 .map(|r| r.blank_lines)
4916 .sum::<u64>();
4917 let mixed_lines = run
4918 .totals_by_language
4919 .iter()
4920 .map(|r| r.mixed_lines_separate)
4921 .sum::<u64>();
4922 let functions = run
4923 .totals_by_language
4924 .iter()
4925 .map(|r| r.functions)
4926 .sum::<u64>();
4927 let classes = run
4928 .totals_by_language
4929 .iter()
4930 .map(|r| r.classes)
4931 .sum::<u64>();
4932 let variables = run
4933 .totals_by_language
4934 .iter()
4935 .map(|r| r.variables)
4936 .sum::<u64>();
4937 let imports = run
4938 .totals_by_language
4939 .iter()
4940 .map(|r| r.imports)
4941 .sum::<u64>();
4942
4943 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4944 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4945 let prev_fs = prev_sum.map(|s| s.files_skipped);
4946 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4947 let prev_cl = prev_sum.map(|s| s.code_lines);
4948 let prev_cml = prev_sum.map(|s| s.comment_lines);
4949 let prev_bl = prev_sum.map(|s| s.blank_lines);
4950 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4951 let prev_fa_str = fmt_prev(prev_fa);
4952 let prev_fs_str = fmt_prev(prev_fs);
4953 let prev_pl_str = fmt_prev(prev_pl);
4954 let prev_cl_str = fmt_prev(prev_cl);
4955 let prev_cml_str = fmt_prev(prev_cml);
4956 let prev_bl_str = fmt_prev(prev_bl);
4957 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4958 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4959 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
4960 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
4961 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
4962 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
4963 let delta_fa_class = delta_fa_class.to_string();
4964 let delta_fs_class = delta_fs_class.to_string();
4965 let delta_pl_class = delta_pl_class.to_string();
4966 let delta_cl_class = delta_cl_class.to_string();
4967 let delta_cml_class = delta_cml_class.to_string();
4968 let delta_bl_class = delta_bl_class.to_string();
4969
4970 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
4971 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
4972 let (delta_lines_net_str, delta_lines_net_class) =
4973 match (delta_lines_added, delta_lines_removed) {
4974 (Some(a), Some(r)) => {
4975 let net = a - r;
4976 (fmt_delta(net), delta_class(net).to_string())
4977 }
4978 _ => ("—".to_string(), "na".to_string()),
4979 };
4980
4981 let run_dir = artifacts.output_dir.clone();
4982 let git_branch = run.git_branch.clone();
4983 let git_commit = run.git_commit_short.clone();
4984 let git_commit_long = run.git_commit_long.clone();
4985 let git_author = run.git_commit_author.clone();
4986 let git_commit_url = run
4987 .git_remote_url
4988 .as_deref()
4989 .zip(run.git_commit_long.as_deref())
4990 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
4991 let git_branch_url = run
4992 .git_remote_url
4993 .as_deref()
4994 .zip(run.git_branch.as_deref())
4995 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
4996 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
4997 format!(
4998 "{} / {}",
4999 run.environment.initiator_username, run.environment.initiator_hostname
5000 )
5001 });
5002 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5003 let os_display = format!(
5004 "{} / {}",
5005 run.environment.operating_system, run.environment.architecture
5006 );
5007 let test_count = run.summary_totals.test_count;
5008
5009 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5011 let lsloc = run.summary_totals.lsloc;
5012 let uloc = run.uloc;
5013 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5014 let duplicate_group_count = run.duplicate_groups.len();
5015
5016 let ctx = &artifacts.result_context;
5018 let (
5019 has_cocomo,
5020 cocomo_effort_str,
5021 cocomo_duration_str,
5022 cocomo_staff_str,
5023 cocomo_ksloc_str,
5024 cocomo_mode_label,
5025 cocomo_mode_tooltip,
5026 ) = {
5027 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5028 let mode_str = ctx.cocomo_mode.as_str();
5029 let (a, b, c, d, label, tooltip): (f64, f64, f64, f64, &str, &str) = match mode_str {
5030 "semi_detached" => (3.0, 1.12, 2.5, 0.35, "Semi-detached",
5031 "Semi-detached: A mixed team with varying experience tackling a project with \
5032 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5033 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."),
5034 "embedded" => (3.6, 1.20, 2.5, 0.32, "Embedded",
5035 "Embedded: Tight hardware, software, or operational constraints requiring \
5036 significant innovation and deep integration work. Typical for real-time control \
5037 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."),
5038 _ => (2.4, 1.05, 2.5, 0.38, "Organic",
5039 "Organic: A small team working on a well-understood project in a familiar \
5040 environment with minimal external constraints. Suited for internal tools, \
5041 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."),
5042 };
5043 let effort = a * ksloc.powf(b);
5044 let duration = c * effort.powf(d);
5045 let staff = if duration > 0.0 {
5046 effort / duration
5047 } else {
5048 0.0
5049 };
5050 if run.summary_totals.code_lines > 0 {
5051 (
5052 true,
5053 format!("{:.2}", (effort * 100.0).round() / 100.0),
5054 format!("{:.2}", (duration * 100.0).round() / 100.0),
5055 format!("{:.2}", (staff * 100.0).round() / 100.0),
5056 format!("{:.2}", (ksloc * 100.0).round() / 100.0),
5057 label.to_string(),
5058 tooltip.to_string(),
5059 )
5060 } else {
5061 (
5062 false,
5063 String::new(),
5064 String::new(),
5065 String::new(),
5066 String::new(),
5067 label.to_string(),
5068 tooltip.to_string(),
5069 )
5070 }
5071 };
5072 let complexity_alert = ctx.complexity_alert;
5073
5074 let template = ResultTemplate {
5075 version: env!("CARGO_PKG_VERSION"),
5076 report_title: run.effective_configuration.reporting.report_title.clone(),
5077 project_path: project_path.clone(),
5078 output_dir: display_path(&artifacts.output_dir),
5079 run_id: run_id.to_owned(),
5080 run_id_short: run_id
5081 .split('-')
5082 .next_back()
5083 .unwrap_or(run_id)
5084 .chars()
5085 .take(7)
5086 .collect(),
5087 files_analyzed,
5088 files_skipped,
5089 physical_lines,
5090 code_lines,
5091 comment_lines,
5092 blank_lines,
5093 mixed_lines,
5094 functions,
5095 classes,
5096 variables,
5097 imports,
5098 html_url: artifacts
5099 .html_path
5100 .as_ref()
5101 .map(|_| format!("/runs/html/{run_id}")),
5102 pdf_url: artifacts
5103 .pdf_path
5104 .as_ref()
5105 .map(|_| format!("/runs/pdf/{run_id}")),
5106 json_url: artifacts
5107 .json_path
5108 .as_ref()
5109 .map(|_| format!("/runs/json/{run_id}")),
5110 html_download_url: artifacts
5111 .html_path
5112 .as_ref()
5113 .map(|_| format!("/runs/html/{run_id}?download=1")),
5114 pdf_download_url: artifacts
5115 .pdf_path
5116 .as_ref()
5117 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5118 json_download_url: artifacts
5119 .json_path
5120 .as_ref()
5121 .map(|_| format!("/runs/json/{run_id}?download=1")),
5122 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5123 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5124 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5125 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5126 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5127 prev_fa_str,
5128 prev_fs_str,
5129 prev_pl_str,
5130 prev_cl_str,
5131 prev_cml_str,
5132 prev_bl_str,
5133 delta_fa_str,
5134 delta_fa_class,
5135 delta_fs_str,
5136 delta_fs_class,
5137 delta_pl_str,
5138 delta_pl_class,
5139 delta_cl_str,
5140 delta_cl_class,
5141 delta_cml_str,
5142 delta_cml_class,
5143 delta_bl_str,
5144 delta_bl_class,
5145 delta_lines_added,
5146 delta_lines_removed,
5147 delta_lines_net_str,
5148 delta_lines_net_class,
5149 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
5150 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
5151 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
5152 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
5153 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
5154 d.file_deltas
5155 .iter()
5156 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5157 .map(|f| {
5158 #[allow(clippy::cast_sign_loss)]
5159 let n = f.current_code as u64;
5160 n
5161 })
5162 .sum()
5163 }),
5164 git_branch,
5165 git_branch_url,
5166 git_commit,
5167 git_commit_long,
5168 git_author,
5169 git_commit_url,
5170 scan_performed_by,
5171 scan_time_display,
5172 os_display,
5173 test_count,
5174 current_scan_number: prev_scan_count + 1,
5175 prev_scan_count,
5176 submodule_rows: run
5177 .submodule_summaries
5178 .iter()
5179 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
5180 .collect(),
5181 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
5182 scan_config_url: format!("/runs/scan-config/{run_id}"),
5183 lang_chart_json: {
5184 let mut langs: Vec<&sloc_core::LanguageSummary> =
5185 run.totals_by_language.iter().collect();
5186 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5187 let entries: Vec<String> = langs
5188 .into_iter()
5189 .take(12)
5190 .map(|l| {
5191 let name = l
5192 .language
5193 .display_name()
5194 .replace('\\', "\\\\")
5195 .replace('"', "\\\"");
5196 format!(
5197 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5198 name,
5199 l.code_lines,
5200 l.comment_lines,
5201 l.blank_lines,
5202 l.total_physical_lines,
5203 l.functions,
5204 l.classes,
5205 l.variables,
5206 l.imports,
5207 l.files,
5208 )
5209 })
5210 .collect();
5211 format!("[{}]", entries.join(","))
5212 },
5213 scatter_chart_json: {
5214 let entries: Vec<String> = run
5215 .totals_by_language
5216 .iter()
5217 .map(|l| {
5218 let name = l
5219 .language
5220 .display_name()
5221 .replace('\\', "\\\\")
5222 .replace('"', "\\\"");
5223 format!(
5224 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5225 name, l.files, l.code_lines, l.total_physical_lines,
5226 )
5227 })
5228 .collect();
5229 format!("[{}]", entries.join(","))
5230 },
5231 semantic_chart_json: {
5232 let entries: Vec<String> = run
5233 .totals_by_language
5234 .iter()
5235 .filter(|l| {
5236 l.functions > 0
5237 || l.classes > 0
5238 || l.variables > 0
5239 || l.imports > 0
5240 || l.test_count > 0
5241 })
5242 .map(|l| {
5243 let name = l
5244 .language
5245 .display_name()
5246 .replace('\\', "\\\\")
5247 .replace('"', "\\\"");
5248 format!(
5249 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5250 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5251 )
5252 })
5253 .collect();
5254 format!("[{}]", entries.join(","))
5255 },
5256 submodule_chart_json: {
5257 let entries: Vec<String> = run
5258 .submodule_summaries
5259 .iter()
5260 .map(|s| {
5261 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5262 format!(
5263 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5264 name,
5265 s.code_lines,
5266 s.comment_lines,
5267 s.blank_lines,
5268 s.total_physical_lines,
5269 s.files_analyzed,
5270 )
5271 })
5272 .collect();
5273 format!("[{}]", entries.join(","))
5274 },
5275 has_submodule_data: !run.submodule_summaries.is_empty(),
5276 has_semantic_data: run
5277 .totals_by_language
5278 .iter()
5279 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5280 csp_nonce: csp_nonce.to_owned(),
5281 confluence_configured,
5282 server_mode,
5283 report_header_footer: run
5284 .effective_configuration
5285 .reporting
5286 .report_header_footer
5287 .clone(),
5288 is_offline: false,
5289 cyclomatic_complexity,
5290 lsloc,
5291 uloc,
5292 dryness_pct_str,
5293 duplicate_group_count,
5294 has_cocomo,
5295 cocomo_effort_str,
5296 cocomo_duration_str,
5297 cocomo_staff_str,
5298 cocomo_ksloc_str,
5299 cocomo_mode_label,
5300 cocomo_mode_tooltip,
5301 complexity_alert,
5302 };
5303
5304 Html(
5305 template
5306 .render()
5307 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5308 )
5309 .into_response()
5310}
5311
5312fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5313 let slug: String = report_title
5314 .chars()
5315 .map(|c| {
5316 if c.is_alphanumeric() || c == '-' {
5317 c.to_ascii_lowercase()
5318 } else {
5319 '_'
5320 }
5321 })
5322 .collect::<String>()
5323 .split('_')
5324 .filter(|s| !s.is_empty())
5325 .collect::<Vec<_>>()
5326 .join("_");
5327
5328 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5329
5330 if slug.is_empty() {
5331 format!("report_{short_id}.pdf")
5332 } else {
5333 format!("{slug}_{short_id}.pdf")
5334 }
5335}
5336
5337#[derive(Serialize)]
5338struct PdfStatusResponse {
5339 ready: bool,
5340}
5341
5342async fn pdf_status_handler(
5345 State(state): State<AppState>,
5346 AxumPath(run_id): AxumPath<String>,
5347) -> Response {
5348 let pdf_path = {
5349 let registry = state.artifacts.lock().await;
5350 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5351 };
5352 let pdf_path = if pdf_path.is_some() {
5353 pdf_path
5354 } else {
5355 let reg = state.registry.lock().await;
5356 reg.find_by_run_id(&run_id)
5357 .map(recover_artifacts_from_registry)
5358 .and_then(|a| a.pdf_path)
5359 };
5360 let ready = pdf_path.is_some_and(|p| p.exists());
5361 Json(PdfStatusResponse { ready }).into_response()
5362}
5363
5364async fn download_bundle_handler(
5370 State(state): State<AppState>,
5371 AxumPath(run_id): AxumPath<String>,
5372) -> Response {
5373 let output_dir = {
5375 let cache = state.artifacts.lock().await;
5376 cache.get(&run_id).map(|a| a.output_dir.clone())
5377 };
5378 let output_dir = if let Some(d) = output_dir {
5379 d
5380 } else {
5381 let reg = state.registry.lock().await;
5382 match reg.find_by_run_id(&run_id) {
5383 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5384 None => {
5385 return (
5386 StatusCode::NOT_FOUND,
5387 Json(serde_json::json!({"error": "Run not found"})),
5388 )
5389 .into_response();
5390 }
5391 }
5392 };
5393
5394 if !output_dir.exists() {
5395 return (
5396 StatusCode::NOT_FOUND,
5397 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5398 )
5399 .into_response();
5400 }
5401
5402 let run_id_clone = run_id.clone();
5404 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5405 use flate2::{write::GzEncoder, Compression};
5406 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5407 {
5408 let mut tar = tar::Builder::new(&mut enc);
5409 tar.follow_symlinks(false);
5410 if let Ok(entries) = std::fs::read_dir(&output_dir) {
5413 for entry in entries.filter_map(Result::ok) {
5414 let p = entry.path();
5415 if p.is_file() {
5416 let name = p.file_name().unwrap_or_default().to_string_lossy();
5417 let archive_path = format!("{run_id_clone}/{name}");
5418 tar.append_path_with_name(&p, &archive_path)?;
5419 }
5420 }
5421 }
5422 tar.finish()?;
5423 }
5424 Ok(enc.finish()?)
5425 })
5426 .await;
5427
5428 match archive_result {
5429 Ok(Ok(bytes)) => {
5430 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5431 axum::response::Response::builder()
5432 .status(StatusCode::OK)
5433 .header("Content-Type", "application/gzip")
5434 .header(
5435 "Content-Disposition",
5436 format!("attachment; filename=\"{filename}\""),
5437 )
5438 .header("Content-Length", bytes.len().to_string())
5439 .body(axum::body::Body::from(bytes))
5440 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5441 }
5442 Ok(Err(e)) => (
5443 StatusCode::INTERNAL_SERVER_ERROR,
5444 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5445 )
5446 .into_response(),
5447 Err(e) => (
5448 StatusCode::INTERNAL_SERVER_ERROR,
5449 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5450 )
5451 .into_response(),
5452 }
5453}
5454
5455async fn delete_run_handler(
5460 State(state): State<AppState>,
5461 AxumPath(run_id): AxumPath<String>,
5462) -> Response {
5463 let output_dir = {
5465 let mut cache = state.artifacts.lock().await;
5466 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5467 cache.remove(&run_id);
5468 dir
5469 };
5470 let output_dir = if let Some(d) = output_dir {
5471 d
5472 } else {
5473 let reg = state.registry.lock().await;
5474 reg.find_by_run_id(&run_id)
5475 .map(|e| recover_artifacts_from_registry(e).output_dir)
5476 .unwrap_or_default()
5477 };
5478
5479 {
5481 let mut reg = state.registry.lock().await;
5482 reg.entries.retain(|e| e.run_id != run_id);
5483 let _ = reg.save(&state.registry_path);
5484 }
5485
5486 if output_dir.exists() {
5489 match tokio::fs::remove_dir_all(&output_dir).await {
5490 Ok(()) => {}
5491 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
5492 Err(e) => {
5493 return (
5494 StatusCode::INTERNAL_SERVER_ERROR,
5495 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5496 )
5497 .into_response();
5498 }
5499 }
5500 }
5501
5502 StatusCode::NO_CONTENT.into_response()
5503}
5504
5505async fn cleanup_runs_handler(
5510 State(state): State<AppState>,
5511 Json(body): Json<serde_json::Value>,
5512) -> Response {
5513 let days = body
5514 .get("older_than_days")
5515 .and_then(serde_json::Value::as_u64)
5516 .unwrap_or(30)
5517 .max(1);
5518
5519 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5520
5521 let expired: Vec<(String, PathBuf)> = {
5523 let reg = state.registry.lock().await;
5524 reg.entries
5525 .iter()
5526 .filter(|e| e.timestamp_utc < cutoff)
5527 .map(|e| {
5528 let arts = recover_artifacts_from_registry(e);
5529 (e.run_id.clone(), arts.output_dir)
5530 })
5531 .collect()
5532 };
5533
5534 let mut deleted = 0usize;
5535 for (run_id, output_dir) in &expired {
5536 state.artifacts.lock().await.remove(run_id);
5538 if output_dir.exists() {
5540 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5541 eprintln!(
5542 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5543 output_dir.display()
5544 );
5545 continue;
5546 }
5547 }
5548 deleted += 1;
5549 }
5550
5551 let expired_ids: std::collections::HashSet<&str> =
5553 expired.iter().map(|(id, _)| id.as_str()).collect();
5554 {
5555 let mut reg = state.registry.lock().await;
5556 reg.entries
5557 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5558 let _ = reg.save(&state.registry_path);
5559 }
5560
5561 Json(serde_json::json!({ "deleted": deleted })).into_response()
5562}
5563
5564fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5567 tokio::spawn(async move {
5568 loop {
5569 let interval_secs = {
5570 let store = state.cleanup_policy.lock().await;
5571 match &store.policy {
5572 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5573 _ => break,
5574 }
5575 };
5576 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5577 let n = run_auto_cleanup(&state).await;
5578 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5579 }
5580 })
5581}
5582
5583fn collect_runs_to_delete(
5584 reg: &ScanRegistry,
5585 max_age_days: Option<u32>,
5586 max_run_count: Option<u32>,
5587) -> std::collections::HashSet<String> {
5588 let mut to_delete = std::collections::HashSet::new();
5589 if let Some(days) = max_age_days {
5590 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5591 for e in ®.entries {
5592 if e.timestamp_utc < cutoff {
5593 to_delete.insert(e.run_id.clone());
5594 }
5595 }
5596 }
5597 if let Some(max_count) = max_run_count {
5598 for e in reg.entries.iter().skip(max_count as usize) {
5600 to_delete.insert(e.run_id.clone());
5601 }
5602 }
5603 to_delete
5604}
5605
5606async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5607 let output_dir = {
5608 let mut cache = state.artifacts.lock().await;
5609 let d = cache.get(run_id).map(|a| a.output_dir.clone());
5610 cache.remove(run_id);
5611 d
5612 };
5613 let output_dir = if let Some(d) = output_dir {
5614 d
5615 } else {
5616 let reg = state.registry.lock().await;
5617 reg.find_by_run_id(run_id)
5618 .map(|e| recover_artifacts_from_registry(e).output_dir)
5619 .unwrap_or_default()
5620 };
5621 if output_dir.exists() {
5622 let _ = tokio::fs::remove_dir_all(&output_dir).await;
5623 }
5624}
5625
5626async fn run_auto_cleanup(state: &AppState) -> u32 {
5630 let (max_age_days, max_run_count) = {
5631 let store = state.cleanup_policy.lock().await;
5632 match &store.policy {
5633 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5634 _ => return 0,
5635 }
5636 };
5637
5638 let to_delete = {
5639 let reg = state.registry.lock().await;
5640 collect_runs_to_delete(®, max_age_days, max_run_count)
5641 };
5642
5643 for run_id in &to_delete {
5644 delete_run_artifacts(state, run_id).await;
5645 }
5646
5647 if !to_delete.is_empty() {
5649 let mut reg = state.registry.lock().await;
5650 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5651 let _ = reg.save(&state.registry_path);
5652 }
5653
5654 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5655 {
5656 let mut store = state.cleanup_policy.lock().await;
5657 store.last_run_at = Some(chrono::Utc::now());
5658 store.last_run_deleted = Some(deleted);
5659 let _ = store.save(&state.cleanup_policy_path);
5660 }
5661 deleted
5662}
5663
5664async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5668 let store = state.cleanup_policy.lock().await;
5669 Json(serde_json::json!({
5670 "policy": store.policy,
5671 "last_run_at": store.last_run_at,
5672 "last_run_deleted": store.last_run_deleted,
5673 }))
5674 .into_response()
5675}
5676
5677async fn api_save_cleanup_policy(
5679 State(state): State<AppState>,
5680 Json(body): Json<CleanupPolicy>,
5681) -> Response {
5682 {
5684 let mut handle = state.cleanup_task_handle.lock().await;
5685 if let Some(h) = handle.take() {
5686 h.abort();
5687 }
5688 }
5689 {
5690 let mut store = state.cleanup_policy.lock().await;
5691 store.policy = Some(body.clone());
5692 if let Err(e) = store.save(&state.cleanup_policy_path) {
5693 return (
5694 StatusCode::INTERNAL_SERVER_ERROR,
5695 Json(serde_json::json!({"error": e.to_string()})),
5696 )
5697 .into_response();
5698 }
5699 }
5700 if body.enabled {
5701 let handle = spawn_cleanup_policy_task(state.clone());
5702 *state.cleanup_task_handle.lock().await = Some(handle);
5703 }
5704 StatusCode::NO_CONTENT.into_response()
5705}
5706
5707async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5709 let deleted = run_auto_cleanup(&state).await;
5710 Json(serde_json::json!({ "deleted": deleted })).into_response()
5711}
5712
5713async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5715 {
5716 let mut handle = state.cleanup_task_handle.lock().await;
5717 if let Some(h) = handle.take() {
5718 h.abort();
5719 }
5720 }
5721 {
5722 let mut store = state.cleanup_policy.lock().await;
5723 store.policy = None;
5724 let _ = store.save(&state.cleanup_policy_path);
5725 }
5726 StatusCode::NO_CONTENT.into_response()
5727}
5728
5729fn swap_inline_chart_js_for_static(html: String) -> String {
5735 let Some(head_end) = html.find("</head>") else {
5736 return html;
5737 };
5738 let Some(script_start) = html[..head_end].rfind("<script") else {
5739 return html;
5740 };
5741 let Some(close_offset) = html[script_start..].find("</script>") else {
5742 return html;
5743 };
5744 let block_end = script_start + close_offset + "</script>".len();
5745 format!(
5746 "{}<script src=\"/static/chart-report.js\"></script>{}",
5747 &html[..script_start],
5748 &html[block_end..]
5749 )
5750}
5751
5752fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5754 let Some(start) = html.find("nonce=\"") else {
5756 return html
5760 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5761 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5762 };
5763 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
5765 return html.to_owned();
5766 };
5767 let old_nonce = &html[value_start..value_start + end_offset];
5768 html.replace(
5769 &format!("nonce=\"{old_nonce}\""),
5770 &format!("nonce=\"{new_nonce}\""),
5771 )
5772}
5773
5774fn serve_html_artifact(
5775 path: &Path,
5776 wants_download: bool,
5777 csp_nonce: &str,
5778 run_id: &str,
5779 server_mode: bool,
5780) -> Response {
5781 match fs::read_to_string(path) {
5782 Ok(raw) => {
5783 let content = patch_html_nonce(&raw, csp_nonce);
5785 if wants_download {
5786 (
5788 [
5789 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5790 (
5791 header::CONTENT_DISPOSITION,
5792 "attachment; filename=report.html",
5793 ),
5794 ],
5795 content,
5796 )
5797 .into_response()
5798 } else {
5799 Html(swap_inline_chart_js_for_static(content)).into_response()
5802 }
5803 }
5804 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5805 let filename = path.file_name().map_or_else(
5806 || "report.html".to_string(),
5807 |n| n.to_string_lossy().into_owned(),
5808 );
5809 let html = LocateFileTemplate {
5810 run_id: run_id.to_owned(),
5811 artifact_type: "html".to_string(),
5812 expected_filename: filename,
5813 server_mode,
5814 csp_nonce: csp_nonce.to_owned(),
5815 version: env!("CARGO_PKG_VERSION"),
5816 }
5817 .render()
5818 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5819 (StatusCode::NOT_FOUND, Html(html)).into_response()
5820 }
5821 Err(err) => {
5822 let filename = path.file_name().map_or_else(
5823 || "report.html".to_string(),
5824 |n| n.to_string_lossy().into_owned(),
5825 );
5826 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5827 let html = ErrorTemplate {
5828 message: msg,
5829 last_report_url: Some("/view-reports".to_string()),
5830 last_report_label: Some("View Reports".to_string()),
5831 run_id: None,
5832 error_code: Some(404),
5833 csp_nonce: csp_nonce.to_owned(),
5834 version: env!("CARGO_PKG_VERSION"),
5835 }
5836 .render()
5837 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5838 (StatusCode::NOT_FOUND, Html(html)).into_response()
5839 }
5840 }
5841}
5842
5843fn serve_pdf_artifact(
5845 path: &Path,
5846 report_title: &str,
5847 run_id: &str,
5848 wants_download: bool,
5849 csp_nonce: &str,
5850) -> Response {
5851 match fs::read(path) {
5852 Ok(bytes) => {
5853 let filename = build_pdf_filename(report_title, run_id);
5854 let disposition = if wants_download {
5855 format!("attachment; filename=\"{filename}\"")
5856 } else {
5857 format!("inline; filename=\"{filename}\"")
5858 };
5859 (
5860 [
5861 (header::CONTENT_TYPE, "application/pdf".to_string()),
5862 (header::CONTENT_DISPOSITION, disposition),
5863 ],
5864 bytes,
5865 )
5866 .into_response()
5867 }
5868 Err(err) => {
5869 let filename = path.file_name().map_or_else(
5870 || "report.pdf".to_string(),
5871 |n| n.to_string_lossy().into_owned(),
5872 );
5873 let msg = format!(
5874 "PDF report '{filename}' could not be read.\n\n\
5875 Error: {err}\n\n\
5876 If you moved or renamed the output folder, the stored path is now stale. \
5877 Use 'Open PDF folder' from the results page to browse the output directory."
5878 );
5879 let html = ErrorTemplate {
5880 message: msg,
5881 last_report_url: Some("/view-reports".to_string()),
5882 last_report_label: Some("View Reports".to_string()),
5883 run_id: Some(run_id.to_owned()),
5884 error_code: Some(404),
5885 csp_nonce: csp_nonce.to_owned(),
5886 version: env!("CARGO_PKG_VERSION"),
5887 }
5888 .render()
5889 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5890 (StatusCode::NOT_FOUND, Html(html)).into_response()
5891 }
5892 }
5893}
5894
5895fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5897 match fs::read(path) {
5898 Ok(bytes) => {
5899 if wants_download {
5900 (
5901 [
5902 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5903 (
5904 header::CONTENT_DISPOSITION,
5905 "attachment; filename=result.json",
5906 ),
5907 ],
5908 bytes,
5909 )
5910 .into_response()
5911 } else {
5912 (
5913 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5914 bytes,
5915 )
5916 .into_response()
5917 }
5918 }
5919 Err(err) => {
5920 let filename = path.file_name().map_or_else(
5921 || "result.json".to_string(),
5922 |n| n.to_string_lossy().into_owned(),
5923 );
5924 let msg = format!(
5925 "JSON result '{filename}' could not be read.\n\n\
5926 Error: {err}\n\n\
5927 If you moved or renamed the output folder, the stored path is now stale. \
5928 Use 'Open JSON folder' from the results page to browse the output directory."
5929 );
5930 let html = ErrorTemplate {
5931 message: msg,
5932 last_report_url: Some("/view-reports".to_string()),
5933 last_report_label: Some("View Reports".to_string()),
5934 run_id: None,
5935 error_code: Some(404),
5936 csp_nonce: csp_nonce.to_owned(),
5937 version: env!("CARGO_PKG_VERSION"),
5938 }
5939 .render()
5940 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5941 (StatusCode::NOT_FOUND, Html(html)).into_response()
5942 }
5943 }
5944}
5945
5946fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5948 let output_dir = entry
5951 .html_path
5952 .as_ref()
5953 .or(entry.json_path.as_ref())
5954 .or(entry.pdf_path.as_ref())
5955 .or(entry.csv_path.as_ref())
5956 .or(entry.xlsx_path.as_ref())
5957 .and_then(|p| {
5958 let parent = p.parent()?;
5959 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
5960 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
5962 parent.parent().map(PathBuf::from)
5963 } else {
5964 Some(parent.to_path_buf())
5965 }
5966 })
5967 .unwrap_or_default();
5968 let pdf_path = entry.pdf_path.clone().or_else(|| {
5971 let candidate = output_dir.join("report.pdf");
5972 candidate.exists().then_some(candidate)
5973 });
5974 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
5978 for dir in &[output_dir.join("excel"), output_dir.clone()] {
5980 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
5981 entries
5982 .filter_map(std::result::Result::ok)
5983 .find(|e| {
5984 let n = e.file_name();
5985 let n = n.to_string_lossy();
5986 n.starts_with("report_") && n.ends_with(ext)
5987 })
5988 .map(|e| e.path())
5989 }) {
5990 return Some(p);
5991 }
5992 }
5993 None
5994 };
5995
5996 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
5997 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
5998 RunArtifacts {
5999 output_dir: output_dir.clone(),
6000 html_path: entry.html_path.clone(),
6001 pdf_path,
6002 json_path: entry.json_path.clone(),
6003 csv_path,
6004 xlsx_path,
6005 scan_config_path: find_scan_config_in_dir(&output_dir),
6006 report_title: entry.project_label.clone(),
6007 result_context: RunResultContext::default(),
6008 }
6009}
6010
6011#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
6013 state: &AppState,
6014 run_id: &str,
6015 csp_nonce: &str,
6016) -> Result<RunArtifacts, Response> {
6017 let cached = state.artifacts.lock().await.get(run_id).cloned();
6018 if let Some(a) = cached {
6019 return Ok(a);
6020 }
6021 let reg = state.registry.lock().await;
6022 if let Some(entry) = reg.find_by_run_id(run_id) {
6023 return Ok(recover_artifacts_from_registry(entry));
6024 }
6025 drop(reg);
6026 let short_id = &run_id[..run_id.len().min(8)];
6027 let hint = if matches!(
6028 run_id,
6029 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6030 ) {
6031 format!(
6032 " The URL format appears to be reversed — \
6033 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6034 Use the View Reports page to navigate to your scan."
6035 )
6036 } else {
6037 " The report may have been deleted or the report directory moved. \
6038 Use View Reports to browse your scan history."
6039 .to_string()
6040 };
6041 let error_html = ErrorTemplate {
6042 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6043 last_report_url: Some("/view-reports".to_string()),
6044 last_report_label: Some("View Reports".to_string()),
6045 run_id: None,
6046 error_code: Some(404),
6047 csp_nonce: csp_nonce.to_owned(),
6048 version: env!("CARGO_PKG_VERSION"),
6049 }
6050 .render()
6051 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6052 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6053}
6054
6055async fn resolve_or_queue_pdf(
6060 state: &AppState,
6061 pdf_path: Option<PathBuf>,
6062 json_path: Option<PathBuf>,
6063 output_dir: PathBuf,
6064 run_id: &str,
6065 report_title: &str,
6066 csp_nonce: &str,
6067) -> Result<PathBuf, Response> {
6068 if let Some(p) = pdf_path {
6069 return Ok(p);
6070 }
6071 let Some(json_src) = json_path.filter(|p| p.exists()) else {
6072 let msg = "PDF report was not generated for this run. \
6073 Re-run the analysis with PDF output enabled."
6074 .to_string();
6075 let html = ErrorTemplate {
6076 message: msg,
6077 last_report_url: Some(format!("/runs/html/{run_id}")),
6078 last_report_label: Some("View HTML Report".to_string()),
6079 run_id: Some(run_id.to_string()),
6080 error_code: Some(404),
6081 csp_nonce: csp_nonce.to_string(),
6082 version: env!("CARGO_PKG_VERSION"),
6083 }
6084 .render()
6085 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6086 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6087 };
6088 let pdf_filename = build_pdf_filename(report_title, run_id);
6089 let pdf_dest = output_dir.join(&pdf_filename);
6090 if !pdf_dest.exists() {
6091 {
6093 let mut map = state.artifacts.lock().await;
6094 if let Some(entry) = map.get_mut(run_id) {
6095 entry.pdf_path = Some(pdf_dest.clone());
6096 }
6097 }
6098 {
6099 let mut reg = state.registry.lock().await;
6100 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6101 e.pdf_path = Some(pdf_dest.clone());
6102 }
6103 let _ = reg.save(&state.registry_path);
6104 }
6105 spawn_native_pdf_background(
6106 json_src,
6107 pdf_dest.clone(),
6108 run_id.to_string(),
6109 state.artifacts.clone(),
6110 );
6111 }
6112 Ok(pdf_dest)
6113}
6114
6115fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6117 let html = format!(
6118 "<!doctype html><html lang=\"en\"><head>\
6119 <meta charset=utf-8>\
6120 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6121 <meta http-equiv=\"refresh\" content=\"5\">\
6122 <title>OxideSLOC | Generating PDF\u{2026}</title>\
6123 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6124 <style nonce=\"{csp_nonce}\">\
6125 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6126 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6127 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6128 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6129 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6130 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6131 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6132 background:var(--bg);color:var(--text);}}\
6133 .top-nav{{position:sticky;top:0;z-index:30;\
6134 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6135 border-bottom:1px solid rgba(255,255,255,0.12);\
6136 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6137 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6138 min-height:56px;display:flex;align-items:center;gap:14px;}}\
6139 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6140 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6141 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6142 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6143 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6144 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6145 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6146 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6147 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6148 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6149 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6150 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6151 justify-content:center;min-height:38px;border-radius:999px;\
6152 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6153 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6154 .theme-toggle .icon-sun{{display:none;}}\
6155 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6156 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6157 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6158 display:flex;align-items:center;justify-content:center;\
6159 min-height:calc(100vh - 56px);}}\
6160 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6161 .panel{{background:var(--surface);border:1px solid var(--line);\
6162 border-radius:var(--radius);box-shadow:var(--shadow);\
6163 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6164 .spin-ring{{width:56px;height:56px;border-radius:50%;\
6165 border:5px solid var(--line);border-top-color:var(--oxide-2);\
6166 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6167 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6168 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6169 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6170 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6171 min-height:42px;padding:0 20px;border-radius:14px;\
6172 border:1px solid var(--line-strong);text-decoration:none;\
6173 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6174 .back-link:hover{{background:var(--line);}}\
6175 </style></head>\
6176 <body>\
6177 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6178 <a class=\"brand\" href=\"/\">\
6179 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6180 <div class=\"brand-copy\">\
6181 <div class=\"brand-title\">OxideSLOC</div>\
6182 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6183 </div>\
6184 </a>\
6185 <div class=\"nav-right\">\
6186 <a class=\"nav-pill\" href=\"/\">Home</a>\
6187 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6188 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6189 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6190 <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>\
6191 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6192 <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>\
6193 </button>\
6194 </div>\
6195 </div></div>\
6196 <div class=\"page\"><div class=\"panel\">\
6197 <div class=\"spin-ring\"></div>\
6198 <h1>Generating PDF\u{2026}</h1>\
6199 <p>The PDF is being generated from the scan results.<br>\
6200 This page refreshes automatically \u{2014} usually a few seconds.</p>\
6201 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6202 </div></div>\
6203 <script nonce=\"{csp_nonce}\">\
6204 (function(){{\
6205 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6206 if(s===\"dark\")b.classList.add(\"dark-theme\");\
6207 var t=document.getElementById(\"theme-toggle\");\
6208 if(t)t.addEventListener(\"click\",function(){{\
6209 var d=b.classList.toggle(\"dark-theme\");\
6210 localStorage.setItem(k,d?\"dark\":\"light\");\
6211 }});\
6212 }})();\
6213 </script>\
6214 </body></html>"
6215 );
6216 Html(html).into_response()
6217}
6218
6219fn render_error_artifact_html(
6221 message: String,
6222 last_report_url: Option<String>,
6223 last_report_label: Option<String>,
6224 run_id: Option<String>,
6225 error_code: Option<u16>,
6226 csp_nonce: &str,
6227) -> String {
6228 ErrorTemplate {
6229 message,
6230 last_report_url,
6231 last_report_label,
6232 run_id,
6233 error_code,
6234 csp_nonce: csp_nonce.to_owned(),
6235 version: env!("CARGO_PKG_VERSION"),
6236 }
6237 .render()
6238 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
6239}
6240
6241fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
6243 fs::read(path).map_or_else(
6244 |_| StatusCode::NOT_FOUND.into_response(),
6245 |bytes| {
6246 let filename = path.file_name().map_or_else(
6247 || fallback_filename.to_string(),
6248 |n| n.to_string_lossy().into_owned(),
6249 );
6250 (
6251 [
6252 (header::CONTENT_TYPE, content_type.to_string()),
6253 (
6254 header::CONTENT_DISPOSITION,
6255 format!("attachment; filename=\"{filename}\""),
6256 ),
6257 ],
6258 bytes,
6259 )
6260 .into_response()
6261 },
6262 )
6263}
6264
6265fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6266 let Some(path) = csv_path else {
6267 let html = render_error_artifact_html(
6268 "CSV report was not generated for this run, or was not recorded in \
6269 the scan registry."
6270 .to_string(),
6271 Some(format!("/runs/html/{run_id}")),
6272 Some("View HTML Report".to_string()),
6273 Some(run_id.to_string()),
6274 Some(404),
6275 csp_nonce,
6276 );
6277 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6278 };
6279 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6280}
6281
6282fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6283 let Some(path) = xlsx_path else {
6284 let html = render_error_artifact_html(
6285 "Excel report was not generated for this run, or was not recorded in \
6286 the scan registry."
6287 .to_string(),
6288 Some(format!("/runs/html/{run_id}")),
6289 Some("View HTML Report".to_string()),
6290 Some(run_id.to_string()),
6291 Some(404),
6292 csp_nonce,
6293 );
6294 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6295 };
6296 serve_binary_download(
6297 &path,
6298 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6299 "report.xlsx",
6300 )
6301}
6302
6303fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6304 let path = artifact_set
6305 .scan_config_path
6306 .as_deref()
6307 .map(std::path::Path::to_path_buf)
6308 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6309 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6310 fs::read(&path).map_or_else(
6311 |_| StatusCode::NOT_FOUND.into_response(),
6312 |bytes| {
6313 (
6314 [
6315 (
6316 header::CONTENT_TYPE,
6317 "application/json; charset=utf-8".to_string(),
6318 ),
6319 (
6320 header::CONTENT_DISPOSITION,
6321 "attachment; filename=\"scan-config.json\"".to_string(),
6322 ),
6323 ],
6324 bytes,
6325 )
6326 .into_response()
6327 },
6328 )
6329}
6330
6331async fn serve_submodule_pdf_arm(
6336 artifact: &str,
6337 artifact_set: RunArtifacts,
6338 wants_download: bool,
6339 run_id: &str,
6340 csp_nonce: &str,
6341) -> Response {
6342 let base = artifact.trim_end_matches("_pdf");
6344 let sub_dir = artifact_set.output_dir.join("submodules");
6345 let pdf_path = sub_dir.join(format!("{base}.pdf"));
6346
6347 if !pdf_path.exists() {
6348 let derived_safe = base.trim_start_matches("sub_");
6350 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6351 let parent_run = read_json(jp).ok()?;
6352 let sub = parent_run
6353 .submodule_summaries
6354 .iter()
6355 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6356 .clone();
6357 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6358 Some((parent_run, sub, parent_path))
6359 });
6360
6361 if let Some((parent_run, sub, parent_path)) = rebuilt {
6362 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6363 let pp = pdf_path.clone();
6364 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6365 }
6366 }
6367
6368 if !pdf_path.exists() {
6369 let html = render_error_artifact_html(
6370 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6371 enabled."
6372 .to_string(),
6373 Some("/view-reports".to_string()),
6374 Some("View Reports".to_string()),
6375 Some(run_id.to_string()),
6376 Some(404),
6377 csp_nonce,
6378 );
6379 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6380 }
6381
6382 serve_pdf_artifact(
6383 &pdf_path,
6384 &artifact_set.report_title,
6385 run_id,
6386 wants_download,
6387 csp_nonce,
6388 )
6389}
6390
6391fn serve_submodule_arm(
6392 artifact: &str,
6393 artifact_set: &RunArtifacts,
6394 wants_download: bool,
6395 csp_nonce: &str,
6396 run_id: &str,
6397 server_mode: bool,
6398) -> Response {
6399 if artifact.len() > 128
6400 || !artifact
6401 .chars()
6402 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6403 {
6404 return StatusCode::BAD_REQUEST.into_response();
6405 }
6406 let filename = format!("{artifact}.html");
6407 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6409 let path = if new_layout.exists() {
6410 new_layout
6411 } else {
6412 artifact_set.output_dir.join(&filename)
6413 };
6414 if !path.exists() {
6415 let html = render_error_artifact_html(
6416 format!(
6417 "Sub-report '{artifact}' was not found in the run directory.\n\
6418 Re-run the analysis with 'Detect and separate git submodules' \
6419 and HTML output enabled."
6420 ),
6421 Some("/view-reports".to_string()),
6422 Some("View Reports".to_string()),
6423 Some(run_id.to_string()),
6424 Some(404),
6425 csp_nonce,
6426 );
6427 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6428 }
6429 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6430}
6431
6432async fn serve_pdf_arm(
6433 state: &AppState,
6434 artifact_set: RunArtifacts,
6435 wants_download: bool,
6436 run_id: &str,
6437 csp_nonce: &str,
6438) -> Response {
6439 let report_title = artifact_set.report_title.clone();
6440 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6441 let stale_html_name = artifact_set
6442 .html_path
6443 .as_deref()
6444 .and_then(|p| p.file_name())
6445 .map(|n| n.to_string_lossy().into_owned());
6446 let path = match resolve_or_queue_pdf(
6447 state,
6448 artifact_set.pdf_path,
6449 artifact_set.json_path.clone(),
6450 artifact_set.output_dir.clone(),
6451 run_id,
6452 &report_title,
6453 csp_nonce,
6454 )
6455 .await
6456 {
6457 Ok(p) => p,
6458 Err(r) => return r,
6459 };
6460 if !path.exists() {
6461 if had_pdf_in_registry {
6465 if let Some(expected_filename) = stale_html_name {
6466 let html = LocateFileTemplate {
6467 run_id: run_id.to_string(),
6468 artifact_type: "pdf".to_string(),
6469 expected_filename,
6470 server_mode: state.server_mode,
6471 csp_nonce: csp_nonce.to_string(),
6472 version: env!("CARGO_PKG_VERSION"),
6473 }
6474 .render()
6475 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6476 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6477 }
6478 }
6479 return pdf_generating_response(run_id, csp_nonce);
6480 }
6481 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6482}
6483
6484async fn artifact_handler(
6485 State(state): State<AppState>,
6486 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6487 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6488 Query(query): Query<ArtifactQuery>,
6489) -> Response {
6490 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6491 Ok(a) => a,
6492 Err(r) => return r,
6493 };
6494
6495 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6496
6497 match artifact.as_str() {
6498 "html" => {
6499 let Some(path) = artifact_set.html_path else {
6500 return StatusCode::NOT_FOUND.into_response();
6501 };
6502 serve_html_artifact(
6503 &path,
6504 wants_download,
6505 &csp_nonce,
6506 &run_id,
6507 state.server_mode,
6508 )
6509 }
6510 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6511 "json" => {
6512 let Some(path) = artifact_set.json_path else {
6513 let html = render_error_artifact_html(
6514 "JSON result was not generated for this run, or was not recorded in \
6515 the scan registry. Re-run the analysis with JSON output enabled."
6516 .to_string(),
6517 Some("/view-reports".to_string()),
6518 Some("View Reports".to_string()),
6519 Some(run_id.clone()),
6520 Some(404),
6521 &csp_nonce,
6522 );
6523 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6524 };
6525 serve_json_artifact(&path, wants_download, &csp_nonce)
6526 }
6527 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6528 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6529 "scan-config" => serve_scan_config_arm(&artifact_set),
6530 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6531 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6532 .await
6533 }
6534 _ if artifact.starts_with("sub_") => serve_submodule_arm(
6535 &artifact,
6536 &artifact_set,
6537 wants_download,
6538 &csp_nonce,
6539 &run_id,
6540 state.server_mode,
6541 ),
6542 _ => StatusCode::NOT_FOUND.into_response(),
6543 }
6544}
6545
6546struct SubmoduleLinkRow {
6549 name: String,
6550 url: String,
6551}
6552
6553struct HistoryEntryRow {
6554 run_id: String,
6555 run_id_short: String,
6556 timestamp: String,
6557 timestamp_utc_ms: i64,
6558 project_label: String,
6559 project_path: String,
6560 files_analyzed: u64,
6561 files_skipped: u64,
6562 code_lines: u64,
6563 comment_lines: u64,
6564 blank_lines: u64,
6565 git_branch: String,
6566 git_commit: String,
6567 has_html: bool,
6568 has_json: bool,
6569 has_pdf: bool,
6570 submodule_links: Vec<SubmoduleLinkRow>,
6571 submodule_names_csv: String,
6573}
6574
6575fn nth_weekday_of_month(
6577 year: i32,
6578 month: u32,
6579 weekday: chrono::Weekday,
6580 n: u32,
6581) -> chrono::NaiveDate {
6582 use chrono::Datelike;
6583 let mut count = 0u32;
6584 let mut day = 1u32;
6585 loop {
6586 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6587 if d.weekday() == weekday {
6588 count += 1;
6589 if count == n {
6590 return d;
6591 }
6592 }
6593 day += 1;
6594 }
6595}
6596
6597fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6601 use chrono::{Datelike, TimeZone};
6602 let year = dt.year();
6603 let dst_start = chrono::Utc.from_utc_datetime(
6604 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6605 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6606 );
6607 let dst_end = chrono::Utc.from_utc_datetime(
6608 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6609 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6610 );
6611 dt >= dst_start && dt < dst_end
6612}
6613
6614fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6615 if is_pacific_dst(dt) {
6616 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6617 .format("%Y-%m-%d %H:%M PDT")
6618 .to_string()
6619 } else {
6620 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6621 .format("%Y-%m-%d %H:%M PST")
6622 .to_string()
6623 }
6624}
6625
6626fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6628 let (offset, tz) = if is_pacific_dst(dt) {
6629 (
6630 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6631 "PDT",
6632 )
6633 } else {
6634 (
6635 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6636 "PST",
6637 )
6638 };
6639 format!(
6640 "{} {tz}",
6641 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6642 )
6643}
6644
6645fn fmt_git_date(iso: &str) -> Option<String> {
6646 chrono::DateTime::parse_from_rfc3339(iso)
6647 .ok()
6648 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6649}
6650
6651fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6652 reg.entries
6653 .iter()
6654 .map(|e| {
6655 let submodule_links = {
6656 let mut links: Vec<SubmoduleLinkRow> = vec![];
6657 let sub_dir = e
6658 .html_path
6659 .as_ref()
6660 .and_then(|p| p.parent())
6661 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6662 if let Some(dir) = sub_dir {
6663 if let Ok(rd) = std::fs::read_dir(dir) {
6664 for entry_res in rd.flatten() {
6665 let fname = entry_res.file_name();
6666 let fname_str = fname.to_string_lossy();
6667 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6668 let stem = &fname_str[..fname_str.len() - 5];
6669 let display = stem[4..].replace('-', " ");
6670 links.push(SubmoduleLinkRow {
6671 name: display,
6672 url: format!("/runs/{stem}/{}", e.run_id),
6673 });
6674 }
6675 }
6676 }
6677 }
6678 links.sort_by(|a, b| a.name.cmp(&b.name));
6679 links
6680 };
6681 let submodule_names_csv = submodule_links
6682 .iter()
6683 .map(|l| l.name.as_str())
6684 .collect::<Vec<_>>()
6685 .join(",");
6686 HistoryEntryRow {
6687 run_id: e.run_id.clone(),
6688 run_id_short: e
6689 .run_id
6690 .split('-')
6691 .next_back()
6692 .unwrap_or(&e.run_id)
6693 .chars()
6694 .take(7)
6695 .collect(),
6696 timestamp: fmt_la_time(e.timestamp_utc),
6697 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6698 project_label: e.project_label.clone(),
6699 project_path: e
6700 .input_roots
6701 .first()
6702 .map(|s| sanitize_path_str(s))
6703 .unwrap_or_default(),
6704 files_analyzed: e.summary.files_analyzed,
6705 files_skipped: e.summary.files_skipped,
6706 code_lines: e.summary.code_lines,
6707 comment_lines: e.summary.comment_lines,
6708 blank_lines: e.summary.blank_lines,
6709 git_branch: e.git_branch.clone().unwrap_or_default(),
6710 git_commit: e.git_commit.clone().unwrap_or_default(),
6711 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6712 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6713 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6714 submodule_links,
6715 submodule_names_csv,
6716 }
6717 })
6718 .collect()
6719}
6720
6721#[derive(Deserialize, Default)]
6722struct HistoryQuery {
6723 linked: Option<String>,
6724 error: Option<String>,
6725}
6726
6727async fn history_handler(
6728 State(state): State<AppState>,
6729 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6730 Query(query): Query<HistoryQuery>,
6731) -> impl IntoResponse {
6732 auto_scan_watched_dirs(&state).await;
6734 let watched_dirs: Vec<String> = {
6735 let wd = state.watched_dirs.lock().await;
6736 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6737 };
6738 let mut entries = {
6739 let reg = state.registry.lock().await;
6740 make_history_rows(®)
6741 };
6742 entries.retain(|e| e.has_html);
6743 let total_scans = entries.len();
6744 let linked_count = query
6745 .linked
6746 .as_deref()
6747 .and_then(|s| s.parse::<usize>().ok())
6748 .unwrap_or(0);
6749 let browse_error = query.error.filter(|s| !s.is_empty());
6750 let template = HistoryTemplate {
6751 version: env!("CARGO_PKG_VERSION"),
6752 entries,
6753 total_scans,
6754 linked_count,
6755 browse_error,
6756 watched_dirs,
6757 csp_nonce,
6758 server_mode: state.server_mode,
6759 };
6760 Html(
6761 template
6762 .render()
6763 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6764 )
6765 .into_response()
6766}
6767
6768async fn compare_select_handler(
6769 State(state): State<AppState>,
6770 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6771) -> impl IntoResponse {
6772 auto_scan_watched_dirs(&state).await;
6773 let watched_dirs: Vec<String> = {
6774 let wd = state.watched_dirs.lock().await;
6775 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6776 };
6777 let mut entries = {
6778 let reg = state.registry.lock().await;
6779 make_history_rows(®)
6780 };
6781 entries.retain(|e| e.has_json);
6782 let total_scans = entries.len();
6783 let template = CompareSelectTemplate {
6784 version: env!("CARGO_PKG_VERSION"),
6785 entries,
6786 total_scans,
6787 watched_dirs,
6788 csp_nonce,
6789 server_mode: state.server_mode,
6790 };
6791 Html(
6792 template
6793 .render()
6794 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6795 )
6796 .into_response()
6797}
6798
6799#[derive(Deserialize, Default)]
6802struct CompareQuery {
6803 a: Option<String>,
6804 b: Option<String>,
6805 sub: Option<String>,
6807 scope: Option<String>,
6809}
6810
6811struct CompareFileDeltaRow {
6812 relative_path: String,
6813 language: String,
6814 status: String,
6815 baseline_code: i64,
6816 current_code: i64,
6817 baseline_code_display: String,
6818 current_code_display: String,
6819 code_delta_str: String,
6820 code_delta_class: String,
6821 comment_delta_str: String,
6822 comment_delta_class: String,
6823 total_delta_str: String,
6824 total_delta_class: String,
6825}
6826
6827fn recompute_summary_from_records(run: &mut AnalysisRun) {
6830 let mut totals = SummaryTotals::default();
6831 for r in &run.per_file_records {
6832 if r.language.is_some() {
6833 totals.files_analyzed += 1;
6834 }
6835 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6836 totals.code_lines += r.effective_counts.code_lines;
6837 totals.comment_lines += r.effective_counts.comment_lines;
6838 totals.blank_lines += r.effective_counts.blank_lines;
6839 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6840 totals.functions += r.raw_line_categories.functions;
6841 totals.classes += r.raw_line_categories.classes;
6842 totals.variables += r.raw_line_categories.variables;
6843 totals.imports += r.raw_line_categories.imports;
6844 totals.test_count += r.raw_line_categories.test_count;
6845 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6846 totals.test_suite_count += r.raw_line_categories.test_suite_count;
6847 if let Some(cov) = &r.coverage {
6848 totals.coverage_lines_found += u64::from(cov.lines_found);
6849 totals.coverage_lines_hit += u64::from(cov.lines_hit);
6850 totals.coverage_functions_found += u64::from(cov.functions_found);
6851 totals.coverage_functions_hit += u64::from(cov.functions_hit);
6852 totals.coverage_branches_found += u64::from(cov.branches_found);
6853 totals.coverage_branches_hit += u64::from(cov.branches_hit);
6854 }
6855 }
6856 totals.files_considered = totals.files_analyzed;
6857 run.summary_totals = totals;
6858}
6859
6860fn fmt_delta(n: i64) -> String {
6861 if n > 0 {
6862 format!("+{n}")
6863 } else {
6864 format!("{n}")
6865 }
6866}
6867
6868fn delta_class(n: i64) -> &'static str {
6869 use std::cmp::Ordering;
6870 match n.cmp(&0) {
6871 Ordering::Greater => "pos",
6872 Ordering::Less => "neg",
6873 Ordering::Equal => "zero",
6874 }
6875}
6876
6877#[allow(clippy::cast_precision_loss)]
6879fn fmt_pct(delta: i64, baseline: u64) -> String {
6880 if baseline == 0 {
6881 return "—".to_string();
6882 }
6883 #[allow(clippy::cast_precision_loss)]
6884 let pct = (delta as f64 / baseline as f64) * 100.0;
6885 if pct > 0.049 {
6886 format!("+{pct:.1}%")
6887 } else if pct < -0.049 {
6888 format!("{pct:.1}%")
6889 } else {
6890 "±0%".to_string()
6891 }
6892}
6893
6894fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6896 prev.map_or_else(
6897 || ("—".to_string(), "na"),
6898 |p| {
6899 #[allow(clippy::cast_possible_wrap)]
6900 let d = curr as i64 - p as i64;
6901 (fmt_delta(d), delta_class(d))
6902 },
6903 )
6904}
6905
6906#[allow(clippy::result_large_err)] fn load_scan_for_compare(
6908 json_path: &std::path::Path,
6909 scan_label: &str,
6910 run_id: &str,
6911 server_mode: bool,
6912 compare_url: &str,
6913 csp_nonce: &str,
6914) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6915 match read_json(json_path) {
6916 Ok(r) => Ok(r),
6917 Err(e) => {
6918 if server_mode {
6919 let html = ErrorTemplate {
6920 message: format!(
6921 "Could not load {scan_label} scan data. The scan output folder may have \
6922 been moved, renamed, or deleted. Re-running the analysis will create \
6923 fresh comparison data."
6924 ),
6925 last_report_url: Some("/compare-scans".to_string()),
6926 last_report_label: Some("Compare Scans".to_string()),
6927 run_id: Some(run_id.to_owned()),
6928 error_code: Some(404),
6929 csp_nonce: csp_nonce.to_owned(),
6930 version: env!("CARGO_PKG_VERSION"),
6931 }
6932 .render()
6933 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6934 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6935 }
6936 let msg = format!(
6937 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6938 json_path.display()
6939 );
6940 let folder_hint = json_path
6941 .parent()
6942 .map(|p| p.display().to_string())
6943 .unwrap_or_default();
6944 Err(missing_scan_relocate_response(
6945 &msg,
6946 run_id,
6947 &folder_hint,
6948 compare_url,
6949 false,
6950 csp_nonce,
6951 ))
6952 }
6953 }
6954}
6955
6956struct ChurnStats {
6957 new_scope: bool,
6958 scope_flag: bool,
6959 churn_rate_str: String,
6960 churn_rate_class: String,
6961}
6962
6963fn compute_churn_stats(
6964 baseline_code: u64,
6965 current_code: u64,
6966 lines_added: i64,
6967 lines_removed: i64,
6968) -> ChurnStats {
6969 let new_scope = baseline_code == 0 && current_code > 0;
6970 #[allow(clippy::cast_precision_loss)]
6971 let churn_pct = if baseline_code > 0 {
6972 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
6973 } else {
6974 0.0
6975 };
6976 #[allow(clippy::cast_precision_loss)]
6977 let scope_flag =
6978 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
6979 let churn_rate_str = if new_scope {
6980 "New".to_string()
6981 } else if baseline_code > 0 {
6982 format!("{churn_pct:.1}%")
6983 } else {
6984 "—".to_string()
6985 };
6986 let churn_rate_class = if new_scope || churn_pct > 20.0 {
6987 "high".to_string()
6988 } else if churn_pct > 5.0 {
6989 "med".to_string()
6990 } else {
6991 "low".to_string()
6992 };
6993 ChurnStats {
6994 new_scope,
6995 scope_flag,
6996 churn_rate_str,
6997 churn_rate_class,
6998 }
6999}
7000
7001fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7005 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7006 if !has_data {
7007 return String::new();
7008 }
7009 let base_str = s
7010 .baseline_coverage_line_pct
7011 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7012 let curr_str = s
7013 .current_coverage_line_pct
7014 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7015 let (delta_str, cls) = match s.coverage_line_pct_delta {
7016 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7017 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7018 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7019 None => ("\u{2014}".into(), "zero"),
7020 };
7021 format!(
7022 r#"<div class="delta-card">
7023 <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>
7024 <div class="delta-card-label">Line coverage</div>
7025 <div class="delta-card-from">Before: {base_str}</div>
7026 <div class="delta-card-to">{curr_str}</div>
7027 <span class="delta-card-change {cls}">{delta_str}</span>
7028 </div>"#
7029 )
7030}
7031
7032#[allow(clippy::ref_option)]
7034fn narrow_run_pair_by_scope(
7035 mut baseline: AnalysisRun,
7036 mut current: AnalysisRun,
7037 active_sub: &Option<String>,
7038 super_scope: bool,
7039) -> (AnalysisRun, AnalysisRun) {
7040 if let Some(ref sub_name) = active_sub {
7041 baseline
7042 .per_file_records
7043 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7044 current
7045 .per_file_records
7046 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7047 recompute_summary_from_records(&mut baseline);
7048 recompute_summary_from_records(&mut current);
7049 } else if super_scope {
7050 baseline.per_file_records.retain(|f| f.submodule.is_none());
7051 current.per_file_records.retain(|f| f.submodule.is_none());
7052 recompute_summary_from_records(&mut baseline);
7053 recompute_summary_from_records(&mut current);
7054 }
7055 (baseline, current)
7056}
7057
7058#[allow(clippy::ref_option)]
7060fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7061 if let Some(ref sub_name) = active_sub {
7062 for run in runs.iter_mut() {
7063 run.per_file_records
7064 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7065 recompute_summary_from_records(run);
7066 }
7067 } else if super_scope {
7068 for run in runs.iter_mut() {
7069 run.per_file_records.retain(|f| f.submodule.is_none());
7070 recompute_summary_from_records(run);
7071 }
7072 }
7073}
7074
7075#[allow(clippy::too_many_lines)]
7076async fn compare_handler(
7077 State(state): State<AppState>,
7078 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7079 Query(query): Query<CompareQuery>,
7080) -> impl IntoResponse {
7081 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7084 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7085 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7086 };
7087
7088 let (maybe_a, maybe_b) = {
7089 let reg = state.registry.lock().await;
7090 (
7091 reg.find_by_run_id(&run_id_a).cloned(),
7092 reg.find_by_run_id(&run_id_b).cloned(),
7093 )
7094 };
7095
7096 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7097 let html = ErrorTemplate {
7098 message: "One or both run IDs were not found in scan history. \
7099 The runs may have been deleted or the registry may have been reset."
7100 .to_string(),
7101 last_report_url: Some("/compare-scans".to_string()),
7102 last_report_label: Some("Compare Scans".to_string()),
7103 run_id: None,
7104 error_code: None,
7105 csp_nonce: csp_nonce.clone(),
7106 version: env!("CARGO_PKG_VERSION"),
7107 }
7108 .render()
7109 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7110 return Html(html).into_response();
7111 };
7112
7113 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7115 (entry_a, entry_b)
7116 } else {
7117 (entry_b, entry_a)
7118 };
7119
7120 if baseline_entry.run_id != run_id_a {
7124 let canonical = format!(
7125 "/compare?a={}&b={}",
7126 baseline_entry.run_id, current_entry.run_id
7127 );
7128 return axum::response::Redirect::to(&canonical).into_response();
7129 }
7130
7131 let (Some(base_json), Some(curr_json)) = (
7132 baseline_entry.json_path.as_ref(),
7133 current_entry.json_path.as_ref(),
7134 ) else {
7135 let html = ErrorTemplate {
7136 message: "Full comparison requires JSON scan data, which was not saved for one or \
7137 both of these runs. JSON is now always saved for new scans — re-run the \
7138 affected projects to enable comparisons."
7139 .to_string(),
7140 last_report_url: Some("/compare-scans".to_string()),
7141 last_report_label: Some("Compare Scans".to_string()),
7142 run_id: None,
7143 error_code: None,
7144 csp_nonce: csp_nonce.clone(),
7145 version: env!("CARGO_PKG_VERSION"),
7146 }
7147 .render()
7148 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7149 return Html(html).into_response();
7150 };
7151
7152 let compare_url = format!(
7153 "/compare?a={}&b={}",
7154 baseline_entry.run_id, current_entry.run_id
7155 );
7156
7157 let baseline_run = match load_scan_for_compare(
7158 base_json,
7159 "baseline",
7160 &baseline_entry.run_id,
7161 state.server_mode,
7162 &compare_url,
7163 &csp_nonce,
7164 ) {
7165 Ok(r) => r,
7166 Err(resp) => return resp,
7167 };
7168 let current_run = match load_scan_for_compare(
7169 curr_json,
7170 "current",
7171 ¤t_entry.run_id,
7172 state.server_mode,
7173 &compare_url,
7174 &csp_nonce,
7175 ) {
7176 Ok(r) => r,
7177 Err(resp) => return resp,
7178 };
7179
7180 let active_submodule = query.sub.clone();
7181 let super_scope_active = query.scope.as_deref() == Some("super");
7182
7183 let submodule_options = baseline_run
7184 .submodule_summaries
7185 .iter()
7186 .chain(current_run.submodule_summaries.iter())
7187 .map(|s| s.name.clone())
7188 .collect::<std::collections::BTreeSet<_>>()
7189 .into_iter()
7190 .collect::<Vec<_>>();
7191 let has_any_submodule_data = !submodule_options.is_empty();
7192
7193 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
7195 baseline_run,
7196 current_run,
7197 &active_submodule,
7198 super_scope_active,
7199 );
7200
7201 let comparison = compute_delta(&effective_baseline, &effective_current);
7202
7203 let file_rows: Vec<CompareFileDeltaRow> = comparison
7204 .file_deltas
7205 .iter()
7206 .map(|d| CompareFileDeltaRow {
7207 relative_path: d.relative_path.clone(),
7208 language: d.language.clone().unwrap_or_else(|| "—".into()),
7209 status: match d.status {
7210 FileChangeStatus::Added => "added".into(),
7211 FileChangeStatus::Removed => "removed".into(),
7212 FileChangeStatus::Modified => "modified".into(),
7213 FileChangeStatus::Unchanged => "unchanged".into(),
7214 },
7215 baseline_code: d.baseline_code,
7216 current_code: d.current_code,
7217 baseline_code_display: if d.status == FileChangeStatus::Added {
7218 "—".into()
7219 } else {
7220 d.baseline_code.to_string()
7221 },
7222 current_code_display: if d.status == FileChangeStatus::Removed {
7223 "—".into()
7224 } else {
7225 d.current_code.to_string()
7226 },
7227 code_delta_str: fmt_delta(d.code_delta),
7228 code_delta_class: delta_class(d.code_delta).into(),
7229 comment_delta_str: fmt_delta(d.comment_delta),
7230 comment_delta_class: delta_class(d.comment_delta).into(),
7231 total_delta_str: fmt_delta(d.total_delta),
7232 total_delta_class: delta_class(d.total_delta).into(),
7233 })
7234 .collect();
7235
7236 let project_path = baseline_entry
7237 .input_roots
7238 .first()
7239 .map(|s| sanitize_path_str(s))
7240 .unwrap_or_default();
7241 let lines_added = sum_added_code_lines(&comparison);
7242 let lines_removed = sum_removed_code_lines(&comparison);
7243 let churn = compute_churn_stats(
7244 comparison.summary.baseline_code,
7245 comparison.summary.current_code,
7246 lines_added,
7247 lines_removed,
7248 );
7249 let s = &comparison.summary;
7250 let template = CompareTemplate {
7251 version: env!("CARGO_PKG_VERSION"),
7252 project_label: baseline_entry.project_label.clone(),
7253 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
7254 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
7255 baseline_run_id: baseline_entry.run_id.clone(),
7256 current_run_id: current_entry.run_id.clone(),
7257 baseline_run_id_short: baseline_entry
7258 .run_id
7259 .split('-')
7260 .next_back()
7261 .unwrap_or(&baseline_entry.run_id)
7262 .chars()
7263 .take(7)
7264 .collect(),
7265 current_run_id_short: current_entry
7266 .run_id
7267 .split('-')
7268 .next_back()
7269 .unwrap_or(¤t_entry.run_id)
7270 .chars()
7271 .take(7)
7272 .collect(),
7273 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
7274 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
7275 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
7276 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
7277 project_path: project_path.clone(),
7278 baseline_code: s.baseline_code,
7279 current_code: s.current_code,
7280 code_lines_delta_str: fmt_delta(s.code_lines_delta),
7281 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
7282 baseline_files: s.baseline_files,
7283 current_files: s.current_files,
7284 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
7285 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
7286 baseline_comments: s.baseline_comments,
7287 current_comments: s.current_comments,
7288 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
7289 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
7290 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
7291 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
7292 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
7293 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
7294 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
7295 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
7296 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
7297 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
7298 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
7299 code_lines_added: lines_added,
7300 code_lines_removed: lines_removed,
7301 new_scope: churn.new_scope,
7302 churn_rate_str: churn.churn_rate_str,
7303 churn_rate_class: churn.churn_rate_class,
7304 scope_flag: churn.scope_flag,
7305 files_added: comparison.files_added,
7306 files_removed: comparison.files_removed,
7307 files_modified: comparison.files_modified,
7308 files_unchanged: comparison.files_unchanged,
7309 file_rows,
7310 baseline_git_author: baseline_entry.git_author.clone(),
7311 current_git_author: current_entry.git_author.clone(),
7312 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
7313 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
7314 baseline_git_tags: baseline_entry.git_tags.clone(),
7315 current_git_tags: current_entry.git_tags.clone(),
7316 baseline_git_commit_date: baseline_entry
7317 .git_commit_date
7318 .as_deref()
7319 .and_then(fmt_git_date),
7320 current_git_commit_date: current_entry
7321 .git_commit_date
7322 .as_deref()
7323 .and_then(fmt_git_date),
7324 project_name: project_path
7325 .rsplit(['/', '\\'])
7326 .find(|s| !s.is_empty())
7327 .unwrap_or(&project_path)
7328 .to_string(),
7329 submodule_options,
7330 has_any_submodule_data,
7331 active_submodule,
7332 super_scope_active,
7333 csp_nonce,
7334 coverage_delta_card: build_coverage_delta_card(s),
7335 baseline_test_count: effective_baseline.summary_totals.test_count,
7336 current_test_count: effective_current.summary_totals.test_count,
7337 baseline_coverage_pct: s.baseline_coverage_line_pct,
7338 current_coverage_pct: s.current_coverage_line_pct,
7339 };
7340
7341 Html(
7342 template
7343 .render()
7344 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7345 )
7346 .into_response()
7347}
7348
7349fn format_number(n: u64) -> String {
7357 let s = n.to_string();
7358 let mut out = String::with_capacity(s.len() + s.len() / 3);
7359 let len = s.len();
7360 for (i, c) in s.chars().enumerate() {
7361 if i > 0 && (len - i).is_multiple_of(3) {
7362 out.push(',');
7363 }
7364 out.push(c);
7365 }
7366 out
7367}
7368
7369const fn badge_char_width(c: char) -> f64 {
7370 match c {
7371 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7372 'm' | 'w' => 9.0,
7373 ' ' => 4.0,
7374 _ => 6.5,
7375 }
7376}
7377
7378#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7379fn badge_text_px(text: &str) -> u32 {
7380 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7381}
7382
7383fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7384 let lw = badge_text_px(label) + 20;
7385 let rw = badge_text_px(value) + 20;
7386 let total = lw + rw;
7387 let lx = lw / 2;
7388 let rx = lw + rw / 2;
7389 let le = escape_html(label);
7390 let ve = escape_html(value);
7391 let ce = escape_html(color);
7392 format!(
7393 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7394 <rect width="{total}" height="20" fill="#555"/>
7395 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7396 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7397 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7398 <text x="{lx}" y="13">{le}</text>
7399 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7400 <text x="{rx}" y="13">{ve}</text>
7401 </g>
7402</svg>"##
7403 )
7404}
7405
7406#[derive(Deserialize)]
7407struct BadgeQuery {
7408 label: Option<String>,
7409 color: Option<String>,
7410}
7411
7412async fn badge_handler(
7413 State(state): State<AppState>,
7414 AxumPath(metric): AxumPath<String>,
7415 Query(query): Query<BadgeQuery>,
7416) -> Response {
7417 let entry = {
7418 let reg = state.registry.lock().await;
7419 reg.entries.first().cloned()
7420 };
7421
7422 let Some(entry) = entry else {
7423 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7424 return (
7425 [
7426 (header::CONTENT_TYPE, "image/svg+xml"),
7427 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7428 ],
7429 svg,
7430 )
7431 .into_response();
7432 };
7433
7434 let (default_label, value, default_color) = match metric.as_str() {
7435 "code-lines" => (
7436 "code lines",
7437 format_number(entry.summary.code_lines),
7438 "#4a78ee",
7439 ),
7440 "files" => (
7441 "files analyzed",
7442 format_number(entry.summary.files_analyzed),
7443 "#4a9862",
7444 ),
7445 "comment-lines" => (
7446 "comment lines",
7447 format_number(entry.summary.comment_lines),
7448 "#b35428",
7449 ),
7450 "blank-lines" => (
7451 "blank lines",
7452 format_number(entry.summary.blank_lines),
7453 "#7a5db0",
7454 ),
7455 _ => return StatusCode::NOT_FOUND.into_response(),
7456 };
7457
7458 let label = query.label.as_deref().unwrap_or(default_label);
7459 let color = query.color.as_deref().unwrap_or(default_color);
7460 let svg = render_badge_svg(label, &value, color);
7461
7462 (
7463 [
7464 (header::CONTENT_TYPE, "image/svg+xml"),
7465 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7466 ],
7467 svg,
7468 )
7469 .into_response()
7470}
7471
7472#[derive(Serialize)]
7480struct ApiCoverageBlock {
7481 lines_found: u64,
7482 lines_hit: u64,
7483 line_pct: f64,
7484 functions_found: u64,
7485 functions_hit: u64,
7486 function_pct: f64,
7487 branches_found: u64,
7488 branches_hit: u64,
7489 branch_pct: f64,
7490}
7491
7492#[derive(Serialize)]
7493struct ApiMetricsResponse {
7494 run_id: String,
7495 timestamp: String,
7496 project: String,
7497 summary: ApiSummaryPayload,
7498 languages: Vec<ApiLanguageRow>,
7499 #[serde(skip_serializing_if = "Option::is_none")]
7500 coverage: Option<ApiCoverageBlock>,
7501}
7502
7503#[derive(Serialize)]
7504struct ApiSummaryPayload {
7505 files_analyzed: u64,
7506 files_skipped: u64,
7507 code_lines: u64,
7508 comment_lines: u64,
7509 blank_lines: u64,
7510 total_physical_lines: u64,
7511 functions: u64,
7512 classes: u64,
7513 variables: u64,
7514 imports: u64,
7515}
7516
7517#[derive(Serialize)]
7518struct ApiLanguageRow {
7519 name: String,
7520 files: u64,
7521 code_lines: u64,
7522 comment_lines: u64,
7523 blank_lines: u64,
7524 functions: u64,
7525 classes: u64,
7526 variables: u64,
7527 imports: u64,
7528}
7529
7530async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7531 let entry = {
7532 let reg = state.registry.lock().await;
7533 reg.entries.first().cloned()
7534 };
7535 entry.map_or_else(
7536 || error::not_found("no scans recorded yet"),
7537 |e| build_metrics_response(&e),
7538 )
7539}
7540
7541async fn api_metrics_run_handler(
7542 State(state): State<AppState>,
7543 AxumPath(run_id): AxumPath<String>,
7544) -> Response {
7545 let entry = {
7546 let reg = state.registry.lock().await;
7547 reg.find_by_run_id(&run_id).cloned()
7548 };
7549 entry.map_or_else(
7550 || error::not_found("run not found"),
7551 |e| build_metrics_response(&e),
7552 )
7553}
7554
7555fn build_metrics_response(entry: &RegistryEntry) -> Response {
7556 let languages: Vec<ApiLanguageRow> = entry
7557 .json_path
7558 .as_ref()
7559 .and_then(|p| read_json(p).ok())
7560 .map(|run| {
7561 run.totals_by_language
7562 .iter()
7563 .map(|l| ApiLanguageRow {
7564 name: l.language.display_name().to_string(),
7565 files: l.files,
7566 code_lines: l.code_lines,
7567 comment_lines: l.comment_lines,
7568 blank_lines: l.blank_lines,
7569 functions: l.functions,
7570 classes: l.classes,
7571 variables: l.variables,
7572 imports: l.imports,
7573 })
7574 .collect()
7575 })
7576 .unwrap_or_default();
7577
7578 let s = &entry.summary;
7579 let coverage = if s.coverage_lines_found > 0 {
7580 let pct = |hit: u64, found: u64| -> f64 {
7581 if found == 0 {
7582 0.0
7583 } else {
7584 #[allow(clippy::cast_precision_loss)]
7585 let v = (hit as f64 / found as f64) * 100.0;
7586 (v * 10.0).round() / 10.0
7587 }
7588 };
7589 Some(ApiCoverageBlock {
7590 lines_found: s.coverage_lines_found,
7591 lines_hit: s.coverage_lines_hit,
7592 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7593 functions_found: s.coverage_functions_found,
7594 functions_hit: s.coverage_functions_hit,
7595 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7596 branches_found: s.coverage_branches_found,
7597 branches_hit: s.coverage_branches_hit,
7598 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7599 })
7600 } else {
7601 None
7602 };
7603 Json(ApiMetricsResponse {
7604 run_id: entry.run_id.clone(),
7605 timestamp: entry.timestamp_utc.to_rfc3339(),
7606 project: entry.project_label.clone(),
7607 summary: ApiSummaryPayload {
7608 files_analyzed: s.files_analyzed,
7609 files_skipped: s.files_skipped,
7610 code_lines: s.code_lines,
7611 comment_lines: s.comment_lines,
7612 blank_lines: s.blank_lines,
7613 total_physical_lines: s.total_physical_lines,
7614 functions: s.functions,
7615 classes: s.classes,
7616 variables: s.variables,
7617 imports: s.imports,
7618 },
7619 languages,
7620 coverage,
7621 })
7622 .into_response()
7623}
7624
7625#[derive(Deserialize)]
7632struct ProjectHistoryQuery {
7633 path: Option<String>,
7634}
7635
7636#[derive(Serialize)]
7637struct ProjectHistoryResponse {
7638 scan_count: usize,
7639 last_scan_id: Option<String>,
7640 last_scan_timestamp: Option<String>,
7641 last_scan_code_lines: Option<u64>,
7642 last_git_branch: Option<String>,
7643 last_git_commit: Option<String>,
7644}
7645
7646fn entry_matches_project(
7649 entry: &RegistryEntry,
7650 root_str: &str,
7651 upload_root: &str,
7652 upload_name_suffix: Option<&str>,
7653) -> bool {
7654 if entry.input_roots.iter().any(|r| r == root_str) {
7655 return true;
7656 }
7657 if let Some(suffix) = upload_name_suffix {
7658 return entry
7659 .input_roots
7660 .iter()
7661 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7662 }
7663 false
7664}
7665
7666async fn project_history_handler(
7667 State(state): State<AppState>,
7668 Query(query): Query<ProjectHistoryQuery>,
7669) -> Response {
7670 let path = query.path.unwrap_or_default();
7671 let resolved = resolve_input_path(&path);
7672 let root_str = resolved.to_string_lossy().replace('\\', "/");
7673
7674 let upload_root = std::env::temp_dir()
7679 .join("oxide-sloc-uploads")
7680 .to_string_lossy()
7681 .replace('\\', "/");
7682 let upload_name_suffix: Option<String> =
7683 if state.server_mode && root_str.starts_with(&upload_root) {
7684 resolved
7685 .file_name()
7686 .and_then(|n| n.to_str())
7687 .map(|name| format!("/{name}"))
7688 } else {
7689 None
7690 };
7691 let suffix_ref = upload_name_suffix.as_deref();
7692
7693 let entries: Vec<_> = {
7694 let reg = state.registry.lock().await;
7695 reg.entries
7696 .iter()
7697 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7698 .cloned()
7699 .collect()
7700 };
7701 let scan_count = entries.len();
7702 let last = entries.first();
7703 let last_scan_id = last.map(|e| e.run_id.clone());
7704 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7705 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7706 let last_git_branch = last.and_then(|e| e.git_branch.clone());
7707 let last_git_commit = last.and_then(|e| e.git_commit.clone());
7708
7709 Json(ProjectHistoryResponse {
7710 scan_count,
7711 last_scan_id,
7712 last_scan_timestamp,
7713 last_scan_code_lines,
7714 last_git_branch,
7715 last_git_commit,
7716 })
7717 .into_response()
7718}
7719
7720#[derive(Deserialize)]
7727struct MetricsHistoryQuery {
7728 root: Option<String>,
7729 limit: Option<usize>,
7730 submodule: Option<String>,
7733}
7734
7735#[derive(Serialize)]
7736struct MetricsSubmoduleLink {
7737 name: String,
7738 url: String,
7739}
7740
7741#[derive(Serialize)]
7742struct MetricsHistoryEntry {
7743 run_id: String,
7744 run_id_short: String,
7745 timestamp: String,
7746 commit: Option<String>,
7747 branch: Option<String>,
7748 tags: Vec<String>,
7749 nearest_tag: Option<String>,
7750 code_lines: u64,
7751 comment_lines: u64,
7752 blank_lines: u64,
7753 physical_lines: u64,
7754 files_analyzed: u64,
7755 files_skipped: u64,
7756 test_count: u64,
7757 project_label: String,
7758 html_url: Option<String>,
7759 has_pdf: bool,
7760 submodule_links: Vec<MetricsSubmoduleLink>,
7761 #[serde(skip_serializing_if = "Option::is_none")]
7763 coverage_line_pct: Option<f64>,
7764}
7765
7766fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7767 let mut links: Vec<MetricsSubmoduleLink> = vec![];
7768 let sub_dir = e
7769 .html_path
7770 .as_ref()
7771 .and_then(|p| p.parent())
7772 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7773 let Some(dir) = sub_dir else { return links };
7774 let Ok(rd) = std::fs::read_dir(dir) else {
7775 return links;
7776 };
7777 for entry_res in rd.flatten() {
7778 let fname = entry_res.file_name();
7779 let fname_str = fname.to_string_lossy();
7780 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7781 let stem = &fname_str[..fname_str.len() - 5];
7782 let display = stem[4..].replace('-', " ");
7783 links.push(MetricsSubmoduleLink {
7784 name: display,
7785 url: format!("/runs/{stem}/{}", e.run_id),
7786 });
7787 }
7788 }
7789 links.sort_by(|a, b| a.name.cmp(&b.name));
7790 links
7791}
7792
7793fn apply_submodule_filter(
7794 base: MetricsHistoryEntry,
7795 filter: &str,
7796 e: &sloc_core::history::RegistryEntry,
7797) -> Option<MetricsHistoryEntry> {
7798 let json_path = e.json_path.as_ref()?;
7799 let json_str = std::fs::read_to_string(json_path).ok()?;
7800 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7801 let sub = run
7802 .submodule_summaries
7803 .iter()
7804 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7805 let safe = sanitize_project_label(&sub.name);
7806 let artifact_key = format!("sub_{safe}");
7807 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7808 || base.html_url.clone(),
7809 |run_dir| {
7810 let sub_path = run_dir.join(format!("{artifact_key}.html"));
7811 if sub_path.exists() {
7812 Some(format!("/runs/{artifact_key}/{}", e.run_id))
7813 } else {
7814 base.html_url.clone()
7815 }
7816 },
7817 );
7818
7819 let sub_files: Vec<_> = run
7822 .per_file_records
7823 .iter()
7824 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7825 .collect();
7826 let test_count: u64 = sub_files
7827 .iter()
7828 .map(|r| r.raw_line_categories.test_count)
7829 .sum();
7830 #[allow(clippy::cast_precision_loss)]
7831 let coverage_line_pct: Option<f64> = {
7832 let found: u64 = sub_files
7833 .iter()
7834 .filter_map(|r| r.coverage.as_ref())
7835 .map(|c| u64::from(c.lines_found))
7836 .sum();
7837 let hit: u64 = sub_files
7838 .iter()
7839 .filter_map(|r| r.coverage.as_ref())
7840 .map(|c| u64::from(c.lines_hit))
7841 .sum();
7842 if found > 0 {
7843 let pct = (hit as f64 / found as f64) * 100.0;
7844 Some((pct * 10.0).round() / 10.0)
7845 } else {
7846 None
7847 }
7848 };
7849
7850 Some(MetricsHistoryEntry {
7851 code_lines: sub.code_lines,
7852 comment_lines: sub.comment_lines,
7853 blank_lines: sub.blank_lines,
7854 physical_lines: sub.total_physical_lines,
7855 files_analyzed: sub.files_analyzed,
7856 files_skipped: 0,
7857 test_count,
7858 html_url: sub_html_url,
7859 has_pdf: false,
7860 submodule_links: vec![],
7861 coverage_line_pct,
7862 ..base
7863 })
7864}
7865
7866#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
7868 State(state): State<AppState>,
7869 Query(query): Query<MetricsHistoryQuery>,
7870) -> Response {
7871 let limit = query.limit.unwrap_or(50).min(500);
7872 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7873
7874 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7875 let reg = state.registry.lock().await;
7876 reg.entries
7877 .iter()
7878 .filter(|e| {
7879 query.root.as_ref().is_none_or(|root| {
7880 let resolved = resolve_input_path(root);
7881 let root_str = resolved.to_string_lossy().replace('\\', "/");
7882 e.input_roots.iter().any(|r| r == &root_str)
7883 })
7884 })
7885 .take(limit)
7886 .cloned()
7887 .collect()
7888 };
7889
7890 let entries: Vec<MetricsHistoryEntry> = candidate_entries
7891 .into_iter()
7892 .filter_map(|e| {
7893 let tags = e
7894 .git_tags
7895 .as_deref()
7896 .map(|s| {
7897 s.split(',')
7898 .map(|t| t.trim().to_string())
7899 .filter(|t| !t.is_empty())
7900 .collect()
7901 })
7902 .unwrap_or_default();
7903 let html_url = e
7904 .html_path
7905 .as_ref()
7906 .filter(|p| p.exists())
7907 .map(|_| format!("/runs/html/{}", e.run_id));
7908 let nearest_tag = e.git_nearest_tag.clone();
7909 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7910 let run_id_short: String = e
7911 .run_id
7912 .split('-')
7913 .next_back()
7914 .unwrap_or(&e.run_id)
7915 .chars()
7916 .take(7)
7917 .collect();
7918 let submodule_links = build_entry_submodule_links(&e);
7919 #[allow(clippy::cast_precision_loss)]
7920 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7921 let pct = (e.summary.coverage_lines_hit as f64
7922 / e.summary.coverage_lines_found as f64)
7923 * 100.0;
7924 Some((pct * 10.0).round() / 10.0)
7925 } else {
7926 None
7927 };
7928 let base = MetricsHistoryEntry {
7929 run_id: e.run_id.clone(),
7930 run_id_short,
7931 timestamp: e.timestamp_utc.to_rfc3339(),
7932 commit: e.git_commit.clone(),
7933 branch: e.git_branch.clone(),
7934 tags,
7935 nearest_tag,
7936 code_lines: e.summary.code_lines,
7937 comment_lines: e.summary.comment_lines,
7938 blank_lines: e.summary.blank_lines,
7939 physical_lines: e.summary.total_physical_lines,
7940 files_analyzed: e.summary.files_analyzed,
7941 files_skipped: e.summary.files_skipped,
7942 test_count: e.summary.test_count,
7943 project_label: e.project_label.clone(),
7944 html_url,
7945 has_pdf,
7946 submodule_links,
7947 coverage_line_pct,
7948 };
7949 if let Some(ref filter) = submodule_filter {
7950 apply_submodule_filter(base, filter, &e)
7951 } else {
7952 Some(base)
7953 }
7954 })
7955 .collect();
7956
7957 Json(entries).into_response()
7958}
7959
7960#[derive(Deserialize)]
7964struct MetricsSubmodulesQuery {
7965 root: Option<String>,
7966}
7967
7968#[derive(Serialize)]
7969struct SubmoduleEntry {
7970 name: String,
7971 relative_path: String,
7972}
7973
7974async fn api_metrics_submodules_handler(
7975 State(state): State<AppState>,
7976 Query(query): Query<MetricsSubmodulesQuery>,
7977) -> Response {
7978 let json_paths: Vec<std::path::PathBuf> = {
7979 let reg = state.registry.lock().await;
7980 reg.entries
7981 .iter()
7982 .filter(|e| {
7983 query.root.as_ref().is_none_or(|root| {
7984 let resolved = resolve_input_path(root);
7985 let root_str = resolved.to_string_lossy().replace('\\', "/");
7986 e.input_roots.iter().any(|r| r == &root_str)
7987 })
7988 })
7989 .filter_map(|e| e.json_path.clone())
7990 .collect()
7991 };
7992
7993 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
7994 let mut result: Vec<SubmoduleEntry> = Vec::new();
7995
7996 for path in &json_paths {
7997 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
7998 continue;
7999 };
8000 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8001 continue;
8002 };
8003 for sub in &run.submodule_summaries {
8004 if seen.insert(sub.name.clone()) {
8005 result.push(SubmoduleEntry {
8006 name: sub.name.clone(),
8007 relative_path: sub.relative_path.clone(),
8008 });
8009 }
8010 }
8011 }
8012
8013 result.sort_by(|a, b| a.name.cmp(&b.name));
8014 Json(result).into_response()
8015}
8016
8017#[derive(Deserialize)]
8026struct IngestQuery {
8027 label: Option<String>,
8028}
8029
8030#[derive(Serialize)]
8031struct IngestResponse {
8032 run_id: String,
8033 view_url: String,
8034}
8035
8036async fn api_ingest_handler(
8037 State(state): State<AppState>,
8038 Query(q): Query<IngestQuery>,
8039 Json(run): Json<sloc_core::AnalysisRun>,
8040) -> Response {
8041 let label = q.label.unwrap_or_else(|| {
8042 run.input_roots
8043 .first()
8044 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8045 });
8046
8047 let label_for_task = label.clone();
8048 let result = tokio::task::spawn_blocking(move || {
8049 let html = render_html(&run)?;
8050 let run_id = run.tool.run_id.clone();
8051 let run_id_safe = run_id.len() <= 128
8052 && !run_id.is_empty()
8053 && run_id
8054 .chars()
8055 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8056 if !run_id_safe {
8057 anyhow::bail!(
8058 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8059 );
8060 }
8061 let project_label = sanitize_project_label(&label_for_task);
8062 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8063 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8064 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8065 _ => project_label,
8066 };
8067 let (artifacts, _pending_pdf) = persist_run_artifacts(
8068 &run,
8069 &html,
8070 &output_dir,
8071 &label_for_task,
8072 &file_stem,
8073 RunResultContext::default(),
8074 )?;
8075 Ok::<_, anyhow::Error>((run_id, artifacts, run))
8076 })
8077 .await;
8078
8079 match result {
8080 Ok(Ok((run_id, artifacts, run))) => {
8081 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
8082 (
8083 StatusCode::CREATED,
8084 Json(IngestResponse {
8085 view_url: format!("/view-reports?run_id={run_id}"),
8086 run_id,
8087 }),
8088 )
8089 .into_response()
8090 }
8091 Ok(Err(e)) => error::internal(&format!("{e:#}")),
8092 Err(e) => error::internal(&format!("{e}")),
8093 }
8094}
8095
8096fn html_escape(s: &str) -> String {
8100 s.replace('&', "&")
8101 .replace('<', "<")
8102 .replace('>', ">")
8103 .replace('"', """)
8104}
8105
8106#[allow(clippy::cast_precision_loss)]
8107fn fmt_num(n: i64) -> String {
8108 let a = n.unsigned_abs();
8109 if a >= 1_000_000 {
8110 let v = n as f64 / 1_000_000.0;
8111 let s = format!("{v:.1}");
8112 format!("{}M", s.trim_end_matches(".0"))
8113 } else if a >= 10_000 {
8114 let v = n as f64 / 1_000.0;
8115 let s = format!("{v:.1}");
8116 format!("{}K", s.trim_end_matches(".0"))
8117 } else {
8118 let sign = if n < 0 { "-" } else { "" };
8119 if a < 1_000 {
8120 return format!("{sign}{a}");
8121 }
8122 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
8123 }
8124}
8125
8126fn fmt_comma(n: i64) -> String {
8127 let sign = if n < 0 { "-" } else { "" };
8128 let a = n.unsigned_abs();
8129 if a < 1_000 {
8130 return format!("{sign}{a}");
8131 }
8132 let s = a.to_string();
8133 let bytes = s.as_bytes();
8134 let len = bytes.len();
8135 let mut out = String::with_capacity(len + len / 3);
8136 for (i, &b) in bytes.iter().enumerate() {
8137 if i > 0 && (len - i).is_multiple_of(3) {
8138 out.push(',');
8139 }
8140 out.push(b as char);
8141 }
8142 format!("{sign}{out}")
8143}
8144
8145#[derive(Deserialize, Default)]
8146struct MultiCompareQuery {
8147 runs: Option<String>,
8148 scope: Option<String>,
8150 sub: Option<String>,
8152}
8153
8154#[allow(clippy::too_many_lines)]
8155async fn multi_compare_handler(
8156 State(state): State<AppState>,
8157 Query(params): Query<MultiCompareQuery>,
8158 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8159) -> impl IntoResponse {
8160 let run_ids: Vec<String> = params
8161 .runs
8162 .as_deref()
8163 .unwrap_or("")
8164 .split(',')
8165 .map(|s| s.trim().to_string())
8166 .filter(|s| !s.is_empty())
8167 .collect();
8168
8169 if run_ids.len() < 2 {
8170 return Html(
8171 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
8172 <a href=\"/compare-scans\">Go back</a></p>",
8173 )
8174 .into_response();
8175 }
8176 if run_ids.len() > 20 {
8177 return Html(
8178 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
8179 at once. <a href=\"/compare-scans\">Go back</a></p>",
8180 )
8181 .into_response();
8182 }
8183
8184 let entries: Vec<Option<RegistryEntry>> = {
8186 let reg = state.registry.lock().await;
8187 run_ids
8188 .iter()
8189 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
8190 .collect()
8191 };
8192
8193 for (i, entry) in entries.iter().enumerate() {
8194 if entry.is_none() {
8195 let html = format!(
8196 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
8197 found. <a href=\"/compare-scans\">Go back</a></p>",
8198 run_ids[i]
8199 );
8200 return Html(html).into_response();
8201 }
8202 }
8203
8204 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
8205
8206 for entry in &entries {
8207 if entry.json_path.is_none() {
8208 let html = format!(
8209 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
8210 JSON data — re-run the analysis to enable comparison. \
8211 <a href=\"/compare-scans\">Go back</a></p>",
8212 &entry.run_id
8213 );
8214 return Html(html).into_response();
8215 }
8216 }
8217
8218 entries.sort_by_key(|e| e.timestamp_utc);
8220
8221 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
8223 for entry in &entries {
8224 let path = entry.json_path.as_ref().unwrap();
8225 match read_json(path) {
8226 Ok(r) => runs.push(r),
8227 Err(e) => {
8228 let html = format!(
8229 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
8230 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
8231 &entry.run_id
8232 );
8233 return Html(html).into_response();
8234 }
8235 }
8236 }
8237
8238 let all_sub_names: Vec<String> = {
8240 let mut set = std::collections::BTreeSet::new();
8241 for r in &runs {
8242 for s in &r.submodule_summaries {
8243 set.insert(s.name.clone());
8244 }
8245 }
8246 set.into_iter().collect()
8247 };
8248 let has_submodule_data = !all_sub_names.is_empty();
8249 let active_submodule = params.sub.clone();
8250 let super_scope_active = params.scope.as_deref() == Some("super");
8251
8252 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
8254
8255 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
8256 let project_label = entries
8257 .first()
8258 .map_or("", |e| e.project_label.as_str())
8259 .to_string();
8260 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
8261 let multi = compute_multi_delta(&run_refs);
8262 let html = multi_compare_page(
8263 &multi,
8264 &project_label,
8265 env!("CARGO_PKG_VERSION"),
8266 &csp_nonce,
8267 has_submodule_data,
8268 &all_sub_names,
8269 &runs_csv,
8270 super_scope_active,
8271 active_submodule.as_deref(),
8272 &entries,
8273 );
8274 (
8277 [(axum::http::header::CACHE_CONTROL, "no-store")],
8278 Html(html),
8279 )
8280 .into_response()
8281}
8282
8283const fn multi_delta_class(n: i64) -> &'static str {
8284 match n {
8285 1.. => "pos",
8286 ..=-1 => "neg",
8287 0 => "zero",
8288 }
8289}
8290
8291fn multi_fmt_delta(n: i64) -> String {
8292 if n > 0 {
8293 format!("+{n}")
8294 } else {
8295 format!("{n}")
8296 }
8297}
8298
8299fn js_escape(s: &str) -> String {
8301 use std::fmt::Write as _;
8302 let mut out = String::with_capacity(s.len() + 2);
8303 for c in s.chars() {
8304 match c {
8305 '"' => out.push_str("\\\""),
8306 '\\' => out.push_str("\\\\"),
8307 '\n' => out.push_str("\\n"),
8308 '\r' => out.push_str("\\r"),
8309 '\t' => out.push_str("\\t"),
8310 c if (c as u32) < 0x20 => {
8311 let _ = write!(out, "\\u{:04x}", c as u32);
8312 }
8313 c => out.push(c),
8314 }
8315 }
8316 out
8317}
8318
8319fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
8321 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
8322 return (
8323 "—".to_string(),
8324 "<span class=\"mc-row-val\">—</span>".to_string(),
8325 );
8326 };
8327 let cd = entry
8328 .git_commit_date
8329 .as_deref()
8330 .and_then(fmt_git_date)
8331 .unwrap_or_else(|| "—".to_string());
8332 let au = entry.git_author.as_deref().map_or_else(
8333 || "<span class=\"mc-row-val\">—</span>".to_string(),
8334 |a| {
8335 format!(
8336 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
8337 <span class=\"cmp-author-handle\"></span></span>",
8338 html_escape(a)
8339 )
8340 },
8341 );
8342 (cd, au)
8343}
8344
8345fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
8347 active_sub.map_or_else(
8348 || {
8349 if super_scope_active {
8350 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
8351 } else {
8352 "<span class=\"mc-scope-tag mc-scope-full\">\
8353 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
8354 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
8355 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
8356 <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>\
8357 </svg> Full scan</span>"
8358 .to_string()
8359 }
8360 },
8361 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
8362 )
8363}
8364
8365fn build_mc_scan_strip(
8367 multi: &MultiScanComparison,
8368 entries: &[RegistryEntry],
8369 n: usize,
8370 is_many: bool,
8371 active_sub: Option<&str>,
8372 super_scope_active: bool,
8373 project_label: &str,
8374) -> String {
8375 use std::fmt::Write as _;
8376 let mut scan_strip = String::new();
8377 for (i, pt) in multi.points.iter().enumerate() {
8378 let ts_ms = pt.timestamp.timestamp_millis();
8379 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8380 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
8381 let branch = pt.git_branch.as_deref().unwrap_or("");
8382 let report_link = format!("/runs/html/{}", pt.run_id);
8383 let branch_html = if branch.is_empty() {
8384 "<span class=\"mc-row-val\">—</span>".to_string()
8385 } else {
8386 format!(
8387 "<span class=\"mc-card-branch\">{}</span>",
8388 html_escape(branch)
8389 )
8390 };
8391 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
8392 let tags_html = pt
8393 .git_tags
8394 .as_deref()
8395 .filter(|t| !t.is_empty())
8396 .map(|t| {
8397 let chips = t
8398 .split(',')
8399 .filter(|s| !s.is_empty())
8400 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
8401 .collect::<Vec<_>>()
8402 .join(" ");
8403 format!(
8404 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
8405 <span class=\"mc-row-val\">{chips}</span></div>"
8406 )
8407 })
8408 .unwrap_or_default();
8409 let nearest = pt
8410 .git_nearest_tag
8411 .as_deref()
8412 .map(|t| format!("near {}", html_escape(t)))
8413 .unwrap_or_default();
8414 let arrow = if i < n - 1 && !is_many {
8415 "<div class='mc-arrow'>→</div>"
8416 } else {
8417 ""
8418 };
8419 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
8420 let nearest_html = if nearest.is_empty() {
8421 String::new()
8422 } else {
8423 format!(
8424 "<span class=\"mc-card-nearest-wrap\">\
8425 <span class=\"mc-card-nearest\">{nearest}</span>\
8426 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
8427 </span>"
8428 )
8429 };
8430 write!(
8431 scan_strip,
8432 r#"<div class="mc-card">
8433 <div class="mc-card-header">
8434 <div class="mc-card-num">Scan {num}</div>
8435 <div class="mc-card-project-col">
8436 <div class="mc-card-project">{project_label}</div>
8437 {scope_badge}
8438 </div>
8439 </div>
8440 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
8441 <div class="mc-card-rows">
8442 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
8443 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
8444 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
8445 <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>
8446 {tags_html}
8447 </div>
8448 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
8449 </div>{arrow}"#,
8450 num = i + 1,
8451 commit = html_escape(commit),
8452 commit_date = commit_date_html,
8453 ts_ms = ts_ms,
8454 code = fmt_num(pt.code_lines),
8455 scope_badge = scope_badge,
8456 nearest_html = nearest_html,
8457 )
8458 .unwrap();
8459 }
8460 scan_strip
8461}
8462
8463#[allow(clippy::too_many_lines)]
8465fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
8466 use std::fmt::Write as _;
8467 struct MetricRow<'a> {
8468 label: &'a str,
8469 values: Vec<i64>,
8470 seq_deltas: Vec<i64>,
8471 net_delta: i64,
8472 }
8473 let rows: Vec<MetricRow<'_>> = vec![
8474 MetricRow {
8475 label: "Code Lines",
8476 values: multi.points.iter().map(|p| p.code_lines).collect(),
8477 seq_deltas: multi
8478 .sequential_deltas
8479 .iter()
8480 .map(|d| d.summary.code_lines_delta)
8481 .collect(),
8482 net_delta: multi.total_delta.code_lines_delta,
8483 },
8484 MetricRow {
8485 label: "Files Analyzed",
8486 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
8487 seq_deltas: multi
8488 .sequential_deltas
8489 .iter()
8490 .map(|d| d.summary.files_analyzed_delta)
8491 .collect(),
8492 net_delta: multi.total_delta.files_analyzed_delta,
8493 },
8494 MetricRow {
8495 label: "Comment Lines",
8496 values: multi.points.iter().map(|p| p.comment_lines).collect(),
8497 seq_deltas: multi
8498 .sequential_deltas
8499 .iter()
8500 .map(|d| d.summary.comment_lines_delta)
8501 .collect(),
8502 net_delta: multi.total_delta.comment_lines_delta,
8503 },
8504 MetricRow {
8505 label: "Blank Lines",
8506 values: multi.points.iter().map(|p| p.blank_lines).collect(),
8507 seq_deltas: multi
8508 .sequential_deltas
8509 .iter()
8510 .map(|d| d.summary.blank_lines_delta)
8511 .collect(),
8512 net_delta: multi.total_delta.blank_lines_delta,
8513 },
8514 MetricRow {
8515 label: "Tests",
8516 values: multi.points.iter().map(|p| p.test_count).collect(),
8517 seq_deltas: multi
8518 .points
8519 .windows(2)
8520 .map(|pts| pts[1].test_count - pts[0].test_count)
8521 .collect(),
8522 net_delta: multi.points.last().map_or(0, |l| l.test_count)
8523 - multi.points.first().map_or(0, |f| f.test_count),
8524 },
8525 ];
8526 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
8527 for i in 0..n {
8528 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
8529 if i < n - 1 {
8530 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
8531 }
8532 }
8533 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
8534 let mut metrics_tbody = String::new();
8535 for row in &rows {
8536 metrics_tbody.push_str("<tr>");
8537 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
8538 for i in 0..n {
8539 write!(
8540 metrics_tbody,
8541 "<td class='mc-val-col'>{}</td>",
8542 fmt_comma(row.values[i])
8543 )
8544 .unwrap();
8545 if i < n - 1 {
8546 let d = row.seq_deltas[i];
8547 write!(
8548 metrics_tbody,
8549 "<td class='mc-delta-col {cls}'>{val}</td>",
8550 cls = multi_delta_class(d),
8551 val = multi_fmt_delta(d)
8552 )
8553 .unwrap();
8554 }
8555 }
8556 let nd = row.net_delta;
8557 write!(
8558 metrics_tbody,
8559 "<td class='mc-net-col {cls}'>{val}</td>",
8560 cls = multi_delta_class(nd),
8561 val = multi_fmt_delta(nd)
8562 )
8563 .unwrap();
8564 metrics_tbody.push_str("</tr>");
8565 }
8566 (metrics_thead, metrics_tbody)
8567}
8568
8569fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
8571 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
8572 for (i, pt) in multi.points.iter().enumerate() {
8573 let commit = pt.git_commit.as_deref().unwrap_or("");
8574 let branch = pt.git_branch.as_deref().unwrap_or("");
8575 let tags = pt.git_tags.as_deref().unwrap_or("");
8576 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
8577 let scanned_ms = pt.timestamp.timestamp_millis();
8578 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8579 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
8580 let commit_date = entry
8581 .and_then(|e| e.git_commit_date.as_deref())
8582 .and_then(fmt_git_date)
8583 .unwrap_or_default();
8584 let author = entry
8585 .and_then(|e| e.git_author.as_deref())
8586 .unwrap_or("")
8587 .to_string();
8588 let cov = pt
8589 .coverage_line_pct
8590 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
8591 parts.push(format!(
8592 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}}}"#,
8593 run_id = js_escape(&pt.run_id),
8594 commit = js_escape(commit),
8595 branch = js_escape(branch),
8596 tags = js_escape(tags),
8597 nearest = js_escape(nearest),
8598 commit_date = js_escape(&commit_date),
8599 author = js_escape(&author),
8600 scanned = js_escape(&scanned),
8601 code = pt.code_lines,
8602 comments = pt.comment_lines,
8603 blank = pt.blank_lines,
8604 files = pt.files_analyzed,
8605 tests = pt.test_count,
8606 ));
8607 }
8608 format!("[{}]", parts.join(","))
8609}
8610
8611fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
8613 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
8614 for row in &multi.file_matrix {
8615 let lang = row.language.as_deref().unwrap_or("");
8616 let codes: Vec<String> = row
8617 .code_per_scan
8618 .iter()
8619 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8620 .collect();
8621 let deltas: Vec<String> = row
8622 .code_delta_per_scan
8623 .iter()
8624 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8625 .collect();
8626 parts.push(format!(
8627 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
8628 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
8629 status = row.overall_status,
8630 codes = codes.join(","),
8631 deltas = deltas.join(","),
8632 total = row.total_code_delta,
8633 ));
8634 }
8635 format!("[{}]", parts.join(","))
8636}
8637
8638fn build_mc_file_col_headers(n: usize) -> String {
8640 use std::fmt::Write as _;
8641 let mut out = String::new();
8642 for i in 0..n {
8643 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
8644 if i < n - 1 {
8645 write!(
8646 out,
8647 "<th class='file-delta-col'>Δ→{}</th>",
8648 i + 2
8649 )
8650 .unwrap();
8651 }
8652 }
8653 out
8654}
8655
8656fn build_mc_scope_bar(
8658 has_submodule_data: bool,
8659 sub_names: &[String],
8660 runs_csv: &str,
8661 active_sub: Option<&str>,
8662 super_scope_active: bool,
8663) -> String {
8664 use std::fmt::Write as _;
8665 if !has_submodule_data {
8666 return String::new();
8667 }
8668 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
8669 let full_active = active_sub.is_none() && !super_scope_active;
8670 let mut bar = format!(
8671 r#"<div class="submod-scope-bar">
8672 <span class="submod-scope-label">
8673 <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>
8674 Scope:
8675 </span>
8676 <div class="submod-scope-divider"></div>
8677 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
8678 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
8679 full_cls = if full_active { " active" } else { "" },
8680 super_cls = if super_scope_active { " active" } else { "" },
8681 );
8682 for s in sub_names {
8683 let is_active = active_sub == Some(s.as_str());
8684 write!(
8685 bar,
8686 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
8687 cls = if is_active { " active" } else { "" },
8688 name_enc = html_escape(s),
8689 name_esc = html_escape(s),
8690 )
8691 .unwrap();
8692 }
8693 bar.push_str("\n</div>");
8694 bar
8695}
8696
8697fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
8699 active_sub.map_or_else(
8700 || {
8701 if super_scope_active {
8702 "Super-repo only — ".to_string()
8703 } else {
8704 String::new()
8705 }
8706 },
8707 |s| format!("Submodule: {} — ", html_escape(s)),
8708 )
8709}
8710
8711#[allow(clippy::too_many_lines)]
8712#[allow(clippy::too_many_arguments)]
8713fn multi_compare_page(
8714 multi: &MultiScanComparison,
8715 project_label: &str,
8716 version: &str,
8717 csp_nonce: &str,
8718 has_submodule_data: bool,
8719 sub_names: &[String],
8720 runs_csv: &str,
8721 super_scope_active: bool,
8722 active_sub: Option<&str>,
8723 entries: &[RegistryEntry],
8724) -> String {
8725 let n = multi.points.len();
8726 let is_many = n > 4;
8727 let mc_strip_class = if is_many {
8728 "mc-strip mc-strip-grid"
8729 } else {
8730 "mc-strip"
8731 };
8732
8733 let scan_strip = build_mc_scan_strip(
8735 multi,
8736 entries,
8737 n,
8738 is_many,
8739 active_sub,
8740 super_scope_active,
8741 project_label,
8742 );
8743
8744 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
8746
8747 let points_json = build_mc_points_json(multi, entries);
8749 let file_matrix_json = build_mc_file_matrix_json(multi);
8750
8751 let files_modified = multi
8753 .file_matrix
8754 .iter()
8755 .filter(|f| f.overall_status == "modified")
8756 .count();
8757 let files_added = multi
8758 .file_matrix
8759 .iter()
8760 .filter(|f| f.overall_status == "added")
8761 .count();
8762 let files_removed = multi
8763 .file_matrix
8764 .iter()
8765 .filter(|f| f.overall_status == "removed")
8766 .count();
8767 let files_unchanged = multi
8768 .file_matrix
8769 .iter()
8770 .filter(|f| f.overall_status == "unchanged")
8771 .count();
8772 let total_files = multi.file_matrix.len();
8773
8774 let file_col_headers = build_mc_file_col_headers(n);
8775 let nav_compare_active = "";
8776 let scope_bar_html = build_mc_scope_bar(
8777 has_submodule_data,
8778 sub_names,
8779 runs_csv,
8780 active_sub,
8781 super_scope_active,
8782 );
8783 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
8784
8785 format!(
8786 r##"<!doctype html>
8787<html lang="en">
8788<head>
8789 <meta charset="utf-8">
8790 <meta name="viewport" content="width=device-width, initial-scale=1">
8791 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
8792 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8793 <style nonce="{csp_nonce}">
8794 :root{{--radius:18px;--bg:#f5efe8;--surface:#fbf7f2;--surface-2:#f4ede4;--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;}}
8795 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
8796 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
8797 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;}}
8798 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8799 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
8800 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8801 .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;}}
8802 @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));}}}}
8803 .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);}}
8804 .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;}}
8805 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
8806 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
8807 @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;}}}}
8808 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
8809 .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));}}
8810 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8811 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
8812 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
8813 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
8814 .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;}}
8815 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8816 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
8817 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8818 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8819 .nav-dropdown{{position:relative;display:inline-flex;}}
8820 .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;}}
8821 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8822 .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;}}
8823 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
8824 .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);}}
8825 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
8826 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
8827 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
8828 body:not(.dark-theme) .icon-sun{{display:none;}}
8829 body.dark-theme .icon-moon{{display:none;}}
8830 .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;}}
8831 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8832 .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);}}
8833 .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;}}
8834 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
8835 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
8836 .settings-modal-body{{padding:14px 16px 16px;}}
8837 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
8838 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8839 .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;}}
8840 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
8841 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
8842 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
8843 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
8844 .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;}}
8845 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8846 .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;}}
8847 .btn-back:hover{{background:var(--line);}}
8848 .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;}}
8849 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;}}
8850 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
8851 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
8852 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
8853 .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;}}
8854 .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;}}
8855 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
8856 .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;}}
8857 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
8858 body.dark-theme .mc-card{{background:var(--surface-2);}}
8859 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
8860 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
8861 .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%;}}
8862 .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;}}
8863 .mc-card-commit:hover{{color:var(--oxide);}}
8864 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
8865 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
8866 .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;}}
8867 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
8868 .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;}}
8869 .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;}}
8870 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
8871 .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;}}
8872 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
8873 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
8874 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
8875 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
8876 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
8877 .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);}}
8878 .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);}}
8879 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
8880 .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;}}
8881 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
8882 .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;}}
8883 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
8884 .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;}}
8885 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
8886 .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;}}
8887 .submod-scope-btn:hover{{background:var(--line);}}
8888 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8889 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
8890 .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;}}
8891 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
8892 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8893 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
8894 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
8895 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
8896 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
8897 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
8898 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
8899 .metrics-table .pos{{color:var(--pos);}}
8900 .metrics-table .neg{{color:var(--neg);}}
8901 .metrics-table .zero{{color:var(--muted);}}
8902 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
8903 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8904 .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;}}
8905 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8906 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
8907 .chart-wrap{{width:100%;overflow-x:auto;}}
8908 #mc-chart{{display:block;width:100%;}}
8909 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
8910 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
8911 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
8912 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
8913 .ic-card{{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}}
8914 body.dark-theme .ic-card{{border-color:var(--line-strong);}}
8915 .ic-card-h2{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}}
8916 .ic-card-h2-row{{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}}
8917 .ic-card-h2-row .ic-card-h2{{margin:0;}}
8918 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
8919 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
8920 .ic-cb{{cursor:pointer;transition:filter .15s;}}
8921 .ic-cb:hover{{filter:brightness(1.12);}}
8922 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
8923 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
8924 #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;}}
8925 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8926 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
8927 .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;}}
8928 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8929 .tab-btn:hover:not(.active){{background:var(--line);}}
8930 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
8931 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
8932 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
8933 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
8934 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
8935 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
8936 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
8937 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
8938 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
8939 .table-wrap{{width:100%;overflow-x:auto;}}
8940 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
8941 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
8942 #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;}}
8943 #file-table th.left,#file-table td.left{{text-align:left;}}
8944 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
8945 .file-delta-col{{color:var(--muted);font-size:11px;}}
8946 .file-net-col{{font-weight:800;}}
8947 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
8948 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
8949 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
8950 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
8951 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
8952 .status-badge.modified{{background:#fff2d8;color:#926000;}}
8953 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
8954 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
8955 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
8956 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
8957 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
8958 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
8959 tr.row-added td{{background:rgba(26,143,71,0.04);}}
8960 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
8961 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
8962 tr.row-unchanged td{{color:var(--muted);}}
8963 tr.row-unchanged .status-badge{{opacity:.65;}}
8964 .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;}}
8965 .absent{{color:var(--muted);font-style:italic;}}
8966 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
8967 .pagination-info{{font-size:12px;color:var(--muted);}}
8968 .pagination-btns{{display:flex;gap:5px;}}
8969 .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;}}
8970 .pg-btn:hover:not(:disabled){{background:var(--line);}}
8971 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8972 .pg-btn:disabled{{opacity:.35;cursor:default;}}
8973 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;}}
8974 .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;}}
8975 .export-btn:hover{{background:var(--line);}}
8976 .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;}}
8977 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
8978 .site-footer a{{color:var(--muted);}}
8979 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;}}
8980 body.pdf-mode{{background:#fff!important;}}
8981 .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;}}
8982 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
8983 .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;}}
8984 .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;}}
8985 .mc-modal-title{{font-size:18px;font-weight:800;}}
8986 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
8987 .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;}}
8988 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
8989 .mc-modal-body{{padding:18px 22px;}}
8990 .mc-modal-sec{{margin-bottom:20px;}}
8991 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
8992 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
8993 .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;}}
8994 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
8995 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
8996 .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;}}
8997 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
8998 .mc-modal-row:last-child{{border-bottom:none;}}
8999 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9000 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9001 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9002 .mc-modal-val a:hover{{text-decoration:underline;}}
9003 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9004 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9005 .mc-modal-stat[data-tip]{{cursor:help;}}
9006 #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);}}
9007 .mc-card{{cursor:pointer;}}
9008 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
9009 </style>
9010</head>
9011<body>
9012 <div class="background-watermarks" aria-hidden="true">
9013 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9014 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9015 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9016 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9017 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9018 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9019 </div>
9020 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9021 <div class="top-nav">
9022 <div class="top-nav-inner">
9023 <a class="brand" href="/">
9024 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9025 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
9026 </a>
9027 <div class="nav-right">
9028 <a class="nav-pill" href="/">Home</a>
9029 <div class="nav-dropdown">
9030 <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>
9031 <div class="nav-dropdown-menu">
9032 <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>
9033 </div>
9034 </div>
9035 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
9036 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9037 <div class="nav-dropdown">
9038 <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>
9039 <div class="nav-dropdown-menu">
9040 <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>
9041 </div>
9042 </div>
9043 <div class="server-status-wrap" id="server-status-wrap">
9044 <div class="nav-pill server-online-pill" id="server-status-pill">
9045 <span class="status-dot" id="status-dot"></span>
9046 <span id="server-status-label">Server</span>
9047 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9048 </div>
9049 <div class="server-status-tip">
9050 OxideSLOC is running — accessible on your network.
9051 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9052 </div>
9053 </div>
9054 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9055 <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>
9056 </button>
9057 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9058 <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>
9059 <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>
9060 </button>
9061 </div>
9062 </div>
9063 </div>
9064
9065 <div class="page">
9066 <!-- Hero header -->
9067 <div class="mc-hero">
9068 <div class="mc-hero-header">
9069 <div>
9070 <div class="mc-title">Multi-Scan Timeline</div>
9071 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
9072 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
9073 </div>
9074 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9075 <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>
9076 <div class="export-group" id="mc-top-export-group">
9077 <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>
9078 <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>
9079 </div>
9080 </div>
9081 </div>
9082 {scope_bar_html}
9083 <!-- Scan strip -->
9084 <div class="{mc_strip_class}">{scan_strip}</div>
9085 </div>
9086
9087 <!-- Summary metrics table -->
9088 <div class="panel">
9089 <div class="panel-title">Metric Progression</div>
9090 <div class="table-wrap">
9091 <table class="metrics-table">
9092 <thead>{metrics_thead}</thead>
9093 <tbody>{metrics_tbody}</tbody>
9094 </table>
9095 </div>
9096 </div>
9097
9098 <!-- Scan Charts -->
9099 <div class="panel" id="mc-charts-panel">
9100 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
9101 <div class="ic-grid">
9102 <!-- Timeline line chart — spans full width -->
9103 <div class="ic-card" style="grid-column:span 2">
9104 <div class="ic-card-h2-row">
9105 <span class="ic-card-h2">Timeline</span>
9106 <div class="chart-toolbar" style="margin:0">
9107 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
9108 <button class="chart-metric-btn" data-metric="files">Files</button>
9109 <button class="chart-metric-btn" data-metric="comments">Comments</button>
9110 <button class="chart-metric-btn" data-metric="tests">Tests</button>
9111 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
9112 </div>
9113 </div>
9114 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
9115 </div>
9116 <!-- Code Metrics: Scan 1 vs Latest -->
9117 <div class="ic-card">
9118 <div class="ic-card-h2">Code Metrics — Scan 1 vs Latest</div>
9119 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = scan 1)</span></div>
9120 <div id="mc-ic-c1"></div>
9121 </div>
9122 <!-- Language Code Delta -->
9123 <div class="ic-card" id="mc-ic-lang-card">
9124 <div class="ic-card-h2">Language Code Delta</div>
9125 <div id="mc-ic-c3"></div>
9126 </div>
9127 <!-- Delta by Metric -->
9128 <div class="ic-card">
9129 <div class="ic-card-h2">Delta by Metric</div>
9130 <div id="mc-ic-c2"></div>
9131 </div>
9132 <!-- File Change Distribution -->
9133 <div class="ic-card">
9134 <div class="ic-card-h2">File Change Distribution</div>
9135 <div id="mc-ic-c4"></div>
9136 </div>
9137 </div>
9138 </div>
9139
9140 <!-- File matrix table -->
9141 <div class="panel">
9142 <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>
9143 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
9144 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
9145 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
9146 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
9147 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
9148 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
9149 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
9150 </div>
9151 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9152 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
9153 <div class="export-group">
9154 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
9155 <button type="button" class="export-btn" id="export-csv-btn">
9156 <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>
9157 CSV
9158 </button>
9159 <button type="button" class="export-btn" id="mc-file-xls-btn">
9160 <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>
9161 Excel
9162 </button>
9163 </div>
9164 </div>
9165 </div>
9166 <div class="table-wrap">
9167 <table id="file-table">
9168 <thead>
9169 <tr>
9170 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
9171 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
9172 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
9173 {file_col_headers}
9174 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
9175 </tr>
9176 </thead>
9177 <tbody id="file-tbody"></tbody>
9178 </table>
9179 </div>
9180 <div class="pagination">
9181 <span class="pagination-info" id="pg-info"></span>
9182 <div class="pagination-btns" id="pg-btns"></div>
9183 <div style="display:flex;align-items:center;gap:6px;">
9184 <span style="font-size:12px;color:var(--muted)">Show</span>
9185 <select class="per-page" id="per-page-sel">
9186 <option value="25" selected>25 per page</option>
9187 <option value="50">50 per page</option>
9188 <option value="100">100 per page</option>
9189 </select>
9190 </div>
9191 </div>
9192 </div>
9193 </div>
9194
9195 <div id="mc-ic-tt"></div>
9196
9197 <footer class="site-footer">
9198 oxide-sloc v{version} — local code metrics workbench ·
9199 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9200 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9201 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9202 · <a href="/api-docs" rel="noopener">REST API</a>
9203 </footer>
9204
9205 <script nonce="{csp_nonce}">
9206 (function(){{
9207 // ── Dark theme ───────────────────────────────────────────────────────────
9208 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
9209 var tt=document.getElementById('theme-toggle');
9210 if(tt)tt.addEventListener('click',function(){{
9211 var on=document.body.classList.toggle('dark-theme');
9212 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
9213 renderChart(activeMetric);
9214 }});
9215
9216 // ── Code particles ───────────────────────────────────────────────────────
9217 var container=document.getElementById('code-particles');
9218 if(container){{
9219 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()'];
9220 for(var i=0;i<28;i++){{
9221 (function(idx){{
9222 var el=document.createElement('span');el.className='code-particle';
9223 el.textContent=snips[idx%snips.length];
9224 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
9225 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
9226 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
9227 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
9228 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
9229 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
9230 container.appendChild(el);
9231 }})(i);
9232 }}
9233 }}
9234
9235 // ── Watermarks ───────────────────────────────────────────────────────────
9236 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9237 if(wms.length){{
9238 var placed=[];
9239 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;}}
9240 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];}}
9241 var half=Math.floor(wms.length/2);
9242 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;}});
9243 }}
9244
9245 // ── Settings / colour scheme modal ───────────────────────────────────────
9246 (function(){{
9247 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'}}];
9248 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);}});}}
9249 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
9250 function init(){{
9251 var btn=document.getElementById('settings-btn');if(!btn)return;
9252 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9253 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>';
9254 document.body.appendChild(m);
9255 var g=document.getElementById('scheme-grid');
9256 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);}});
9257 var cl=document.getElementById('settings-close-btn');
9258 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');}});
9259 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9260 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9261 }}
9262 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9263 }})();
9264
9265 // ── Timezone support for scan timestamps ─────────────────────────────────
9266 (function(){{
9267 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';}};
9268 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'';}}}};
9269 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);}});}};
9270 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
9271 window.applyTz(storedTz);
9272 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
9273 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
9274 }})();
9275
9276 // ── Data ────────────────────────────────────────────────────────────────
9277 var POINTS={points_json};
9278 var FILES={file_matrix_json};
9279 var N={n};
9280
9281 // ── fmt helper ───────────────────────────────────────────────────────────
9282 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();}}
9283 function fmtFull(n){{return Number(n).toLocaleString();}}
9284 function fmtDelta(n){{return n>0?'+'+fmt(n):fmt(n);}}
9285
9286 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
9287 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
9288 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));}}
9289 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
9290 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
9291
9292 // ── Timeline chart ───────────────────────────────────────────────────────
9293 var activeMetric='code';
9294 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
9295 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
9296
9297 function renderChart(metric){{
9298 var svg=document.getElementById('mc-chart');if(!svg)return;
9299 var W=svg.getBoundingClientRect().width||800,H=280;
9300 svg.setAttribute('height',H);
9301 var pad={{l:62,r:20,t:32,b:72}};
9302 var dark=document.body.classList.contains('dark-theme');
9303 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
9304 var valid=pts.filter(function(v){{return v!=null;}});
9305 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;}}
9306 var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
9307 if(minV===maxV){{minV=Math.max(0,minV-1);maxV=maxV+1;}}
9308 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
9309 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
9310 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
9311 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
9312 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
9313 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
9314 var parts=[];
9315 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
9316 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>');}}
9317 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
9318 var lineD='';var firstPt=true;
9319 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);}}}}
9320 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
9321 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
9322 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
9323 for(var i=0;i<N;i++){{
9324 if(pts[i]==null)continue;
9325 var cx=xOf(i),cy=yOf(pts[i]);
9326 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
9327 var hasTag=p.tags&&p.tags.length>0;
9328 // Permanent Y-value label above the dot
9329 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmt(pts[i])+'</text>');
9330 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" onclick="window.location=\'/runs/report.html/'+p.run_id+'\'"/>');
9331 var xanchor=i===0?'start':i===N-1?'end':'middle';
9332 // X-axis label at 2× the original size (18 px)
9333 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>');
9334 }}
9335 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
9336 svg.setAttribute('viewBox','0 0 '+W+' '+H);
9337 svg.innerHTML=parts.join('');
9338 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
9339 svg.onmousemove=function(e){{
9340 var rect=svg.getBoundingClientRect();
9341 var scaleX=W/rect.width;
9342 var mouseX=(e.clientX-rect.left)*scaleX;
9343 var nearest=-1,minDist=Infinity;
9344 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;}}}}
9345 if(nearest<0)return;
9346 var nc=xOf(nearest),ny=yOf(pts[nearest]);
9347 var xhair=svg.querySelector('.mc-xhair');
9348 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
9349 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"/>';
9350 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
9351 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
9352 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>';
9353 var bx=rect.left+(nc/W*rect.width)+18;
9354 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
9355 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
9356 }};
9357 svg.onmouseleave=function(){{
9358 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
9359 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
9360 }};
9361 }}
9362
9363 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9364
9365 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
9366 btn.addEventListener('click',function(){{
9367 activeMetric=this.dataset.metric;
9368 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
9369 this.classList.add('active');
9370 renderChart(activeMetric);
9371 }});
9372 }});
9373 if(typeof ResizeObserver!=='undefined'){{
9374 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
9375 }}
9376 renderChart(activeMetric);
9377
9378 // ── File matrix table ────────────────────────────────────────────────────
9379 var activeStatus='';
9380 var currentPage=1;
9381 var perPage=25;
9382 var mcSortCol=null,mcSortAsc=true;
9383
9384 function getFiltered(){{
9385 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
9386 if(!mcSortCol)return data;
9387 var asc=mcSortAsc;
9388 return data.slice().sort(function(a,b){{
9389 var va,vb;
9390 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
9391 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
9392 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
9393 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
9394 else{{return 0;}}
9395 if(asc)return va<vb?-1:va>vb?1:0;
9396 return va<vb?1:va>vb?-1:0;
9397 }});
9398 }}
9399
9400 function renderFilePage(){{
9401 var filtered=getFiltered();
9402 var total=filtered.length;
9403 var totalPages=Math.max(1,Math.ceil(total/perPage));
9404 if(currentPage>totalPages)currentPage=totalPages;
9405 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
9406 var tbody=document.getElementById('file-tbody');if(!tbody)return;
9407 var rows=[];
9408 for(var i=start;i<end;i++){{
9409 var f=filtered[i];
9410 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
9411 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
9412 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
9413 for(var j=0;j<N;j++){{
9414 var cv=f.c[j];
9415 cells+='<td class="file-scan-col">'+(cv!=null?fmt(cv):'<span class="absent">\u2014</span>')+'</td>';
9416 if(j<N-1){{
9417 var dv=f.d[j+1];
9418 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
9419 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
9420 }}
9421 }}
9422 var tc=f.t;
9423 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
9424 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
9425 }}
9426 tbody.innerHTML=rows.join('');
9427
9428 var info=document.getElementById('pg-info');
9429 if(info)info.textContent='Showing '+(total?start+1:0)+'–'+end+' of '+total+' files';
9430 renderPgBtns(totalPages);
9431 }}
9432
9433 function renderPgBtns(totalPages){{
9434 var wrap=document.getElementById('pg-btns');if(!wrap)return;
9435 var btns=[];
9436 function mkBtn(label,page,active,disabled){{
9437 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
9438 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
9439 }}
9440 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
9441 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
9442 if(s>1)btns.push(mkBtn('1',1,false,false));
9443 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
9444 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
9445 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
9446 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
9447 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
9448 wrap.innerHTML=btns.join('');
9449 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
9450 b.addEventListener('click',function(){{
9451 var pg=parseInt(this.dataset.pg,10);
9452 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
9453 }});
9454 }});
9455 }}
9456
9457 // Tab filter
9458 document.querySelectorAll('.tab-btn').forEach(function(btn){{
9459 btn.addEventListener('click',function(){{
9460 activeStatus=this.dataset.status||'';
9461 currentPage=1;
9462 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9463 this.classList.add('active');
9464 renderFilePage();
9465 }});
9466 }});
9467
9468 // Per-page selector
9469 var ppSel=document.getElementById('per-page-sel');
9470 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
9471
9472 // ── Column header sort ───────────────────────────────────────────────────
9473 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
9474 th.addEventListener('click',function(){{
9475 var col=th.dataset.sortCol;
9476 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
9477 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9478 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
9479 }});
9480 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
9481 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
9482 currentPage=1;renderFilePage();
9483 }});
9484 }});
9485
9486 // Reset button also clears sort
9487 var mcResetBtn=document.getElementById('mc-file-reset-btn');
9488 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
9489 mcSortCol=null;mcSortAsc=true;
9490 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9491 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
9492 }});
9493 activeStatus='';currentPage=1;
9494 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9495 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
9496 renderFilePage();
9497 }});
9498
9499 renderFilePage();
9500
9501 // ── CSV export ───────────────────────────────────────────────────────────
9502 var exportBtn=document.getElementById('export-csv-btn');
9503 if(exportBtn)exportBtn.addEventListener('click',function(){{
9504 var header=['File','Language','Status'];
9505 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
9506 header.push('Net Delta');
9507 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
9508 var filtered=getFiltered();
9509 filtered.forEach(function(f){{
9510 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
9511 for(var j=0;j<N;j++){{
9512 cols.push(f.c[j]!=null?f.c[j]:'');
9513 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
9514 }}
9515 cols.push(f.t);
9516 rows.push(cols.join(','));
9517 }});
9518 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
9519 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9520 a.download=mcExportName('csv');a.click();
9521 }});
9522
9523 // ── File matrix extra export buttons ─────────────────────────────────────
9524 (function(){{
9525 var resetBtn=document.getElementById('mc-file-reset-btn');
9526 if(resetBtn)resetBtn.addEventListener('click',function(){{
9527 activeStatus='';currentPage=1;
9528 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9529 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
9530 renderFilePage();
9531 }});
9532
9533 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
9534 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
9535 function mcMakeXlsx(fname){{
9536 var filtered=getFiltered();
9537 var enc=new TextEncoder();
9538 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;}}
9539 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;}}
9540 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
9541 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
9542 var ss=[],si={{}};
9543 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
9544 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9545 function WS(){{
9546 var R=0,buf=[];
9547 function cl(c){{return String.fromCharCode(65+c);}}
9548 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
9549 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
9550 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
9551 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>';}}
9552 return{{sc:sc,nc:nc,row:row,xml:xml}};
9553 }}
9554 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
9555 var proj=mcExportProj();
9556 // \u2500\u2500 Summary sheet \u2500\u2500
9557 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9558 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
9559 r1(s1(0,proj,2));
9560 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
9561 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
9562 r1('');
9563 r1(s1(0,'SCAN SUMMARY',8));
9564 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));
9565 POINTS.forEach(function(p,i){{
9566 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
9567 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));
9568 }});
9569 r1('');
9570 if(POINTS.length>1){{
9571 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
9572 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
9573 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
9574 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)));}};
9575 nr('Code Lines',pf.code,pl.code);
9576 nr('Comment Lines',pf.comments,pl.comments);
9577 nr('Files Analyzed',pf.files,pl.files);
9578 nr('Tests',pf.tests,pl.tests);
9579 r1('');
9580 }}
9581 var cMod=0,cAdd=0,cRem=0,cUnch=0;
9582 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
9583 var totF=FILES.length||1;
9584 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
9585 r1(s1(0,'FILE CHANGES',8));
9586 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
9587 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
9588 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
9589 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
9590 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
9591 var lm={{}};
9592 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;}});
9593 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
9594 if(langs.length){{
9595 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9596 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
9597 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)));}});
9598 }}
9599 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
9600 // \u2500\u2500 File Delta sheet \u2500\u2500
9601 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9602 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
9603 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);}}
9604 hcells+=s2(hc,'Net Delta',3);
9605 r2(hcells);
9606 filtered.forEach(function(f){{
9607 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
9608 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));}}}}
9609 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
9610 r2(cells);
9611 }});
9612 var ncols=3+N+(N-1)+1;
9613 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
9614 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>';
9615 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9616 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>',
9617 '_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>',
9618 '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>',
9619 '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>',
9620 '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>',
9621 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
9622 var zparts=[],zcds=[],zoff=0,znf=0;
9623 ['[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){{
9624 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9625 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]);
9626 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);
9627 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));
9628 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
9629 zoff+=entry.length;znf++;
9630 }});
9631 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
9632 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]);
9633 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
9634 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9635 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9636 out.set(new Uint8Array(eocd),pos);
9637 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
9638 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9639 }}
9640
9641 var xlsBtn=document.getElementById('mc-file-xls-btn');
9642 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
9643
9644 // File matrix HTML export — interactive: sort by column, filter by status
9645 function mcFileBuildHtml(){{
9646 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9647 var hdrs=['File','Language','Status'];
9648 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
9649 hdrs.push('Net \u0394');
9650 var SI=2;
9651 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;}});
9652 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
9653 var cnt={{all:allRows.length}};
9654 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
9655 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
9656 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
9657 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9658 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9659 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
9660 '.sub{{font-size:12px;color:#99aabb;}}'+
9661 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
9662 '.wr{{padding:16px 20px;}}'+
9663 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
9664 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
9665 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
9666 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
9667 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
9668 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
9669 'thead tr{{background:#1a2035;}}'+
9670 '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;}}'+
9671 'th:hover{{background:#2a3050;}}'+
9672 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
9673 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
9674 'tr:nth-child(even) td{{background:#faf7f4;}}'+
9675 'tr:hover td{{background:#f5f0ea;}}'+
9676 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
9677 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
9678 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
9679 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
9680 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
9681 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
9682 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
9683 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
9684 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
9685 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
9686 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
9687 '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>";}}'+
9688 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
9689 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
9690 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
9691 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
9692 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
9693 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
9694 '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("")'+
9695 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
9696 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
9697 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
9698 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
9699 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
9700 'sd=(sc===ci)?-sd:1;sc=ci;'+
9701 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
9702 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
9703 'render();';
9704 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
9705 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
9706 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
9707 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
9708 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
9709 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
9710 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
9711 '<script>'+inlineJs+'<\/script><\/body><\/html>';
9712 }}
9713
9714 var htmlBtn=document.getElementById('mc-file-html-btn');
9715 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
9716 var h=mcFileBuildHtml();
9717 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
9718 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9719 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9720 }});
9721
9722 var pdfBtn=document.getElementById('mc-file-pdf-btn');
9723 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
9724 var btn=pdfBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
9725 var h=mcBuildPdfHtml();
9726 fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:h,filename:mcExportName('files.pdf')}})}})
9727 .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
9728 .then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('files.pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
9729 .catch(function(e){{alert('PDF export failed: '+e.message);}})
9730 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
9731 }});
9732 }})();
9733
9734 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
9735 (function(){{
9736 var OX='#C45C10',GN='#2A6846',RD='#B23030',LGY='#DDDDDD';
9737 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9738 function fmt2(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();}}
9739 function px(n){{return Math.round(n);}}
9740 var _tt=document.getElementById('mc-ic-tt');
9741 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
9742 function addTT(el){{
9743 if(!el)return;
9744 el.addEventListener('mouseover',function(e){{
9745 var t=e.target.closest('[data-ttl]');
9746 if(t&&_tt){{
9747 var ttl=t.getAttribute('data-ttl');
9748 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
9749 _tt.style.display='block';mvTT(e);
9750 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9751 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
9752 }} else {{
9753 if(_tt)_tt.style.display='none';
9754 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9755 }}
9756 }});
9757 el.addEventListener('mouseleave',function(){{
9758 if(_tt)_tt.style.display='none';
9759 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9760 }});
9761 el.addEventListener('mousemove',function(e){{mvTT(e);}});
9762 }}
9763 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';}}
9764 if(N<2)return;
9765 var p0=POINTS[0],pLast=POINTS[N-1];
9766 // Chart 1: Code Metrics — Scan 1 vs Latest (grouped bars, same structure as Scan Delta)
9767 var c1mets=[
9768 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:'#93C5FD',cc:'#2563EB'}},
9769 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:'#C4B5FD',cc:'#7C3AED'}},
9770 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:'#6EE7B7',cc:'#0D9488'}}
9771 ];
9772 var maxV1=Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))*1.15||1;
9773 var C1W=620,C1H=196,c1mt=38,c1mb=30,c1ml=56,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
9774 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9775 for(var gi=1;gi<=4;gi++){{
9776 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
9777 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
9778 c1+='<text x="'+(c1ml-5)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">'+fmt2(gv)+'</text>';
9779 }}
9780 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9781 c1+='<text x="'+(c1ml-5)+'" y="'+(c1mt+c1ph+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">0</text>';
9782 c1mets.forEach(function(m,i){{
9783 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9784 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9785 c1+='<text x="'+cx+'" y="17" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="13" font-weight="700" fill="#444">'+esc(m.l)+'</text>';
9786 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="3" style="cursor:pointer;"/>';
9787 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.bc+'">'+fmt2(m.b)+'</text>';
9788 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="3" style="cursor:pointer;"/>';
9789 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.cc+'">'+fmt2(m.c)+'</text>';
9790 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="500" fill="#888">Scan 1</text>';
9791 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.cc+'">Latest</text>';
9792 }});
9793 c1+='</svg>';
9794 // Chart 2: Delta by Metric (net delta first scan to last)
9795 var mets=[
9796 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#2563EB'}},
9797 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#7C3AED'}},
9798 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:'#0D9488'}}
9799 ];
9800 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
9801 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;
9802 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9803 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9804 mets.forEach(function(m,i){{
9805 var y=16+i*rH,bw=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+fmt2(m.v);
9806 c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="13" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
9807 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;"/>';
9808 if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
9809 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="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}}
9810 }});
9811 c2+='</svg>';
9812 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
9813 var lm={{}};
9814 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;}});
9815 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
9816 var c3='';
9817 if(langs.length){{
9818 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
9819 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;
9820 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9821 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9822 langs.forEach(function(l,i){{
9823 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
9824 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9825 c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
9826 if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
9827 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,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}}
9828 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
9829 }});
9830 c3+='</svg>';
9831 }}
9832 // Chart 4: File Change Distribution (centered donut, legend below)
9833 var fm=0,fa=0,fr=0,fu=0;
9834 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
9835 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:'#CCCCCC'}}].filter(function(s){{return s.v>0;}});
9836 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
9837 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
9838 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">',ang4=-Math.PI/2;
9839 if(segs.length===1){{
9840 c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
9841 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface-2)"/>';
9842 }} else {{
9843 segs.forEach(function(s){{
9844 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
9845 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);
9846 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);
9847 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="white" stroke-width="2.5"/>';
9848 ang4+=sw;
9849 }});
9850 }}
9851 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt2(tot4)+'</text>';
9852 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9853 segs.forEach(function(s,i){{
9854 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
9855 c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
9856 c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt2(s.v)+'</text>';
9857 }});
9858 c4+='</svg>';
9859 // Inject charts
9860 var e1=document.getElementById('mc-ic-c1');if(e1){{e1.innerHTML=c1;addTT(e1);}}
9861 var e2=document.getElementById('mc-ic-c2');if(e2){{e2.innerHTML=c2;addTT(e2);}}
9862 var e3=document.getElementById('mc-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);}}
9863 var e4=document.getElementById('mc-ic-c4');if(e4){{e4.innerHTML=c4;addTT(e4);}}
9864 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
9865
9866 // HTML legend hover → highlight matching SVG bars within the SAME card only
9867 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
9868 var metric=leg.getAttribute('data-highlight');
9869 var parentCard=leg.closest('.ic-card');
9870 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
9871 if(!chartEl)return;
9872 leg.addEventListener('mouseenter',function(){{
9873 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
9874 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
9875 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
9876 x.style.opacity='1';
9877 }} else {{
9878 x.style.opacity='0.28';
9879 }}
9880 }});
9881 }});
9882 leg.addEventListener('mouseleave',function(){{
9883 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9884 }});
9885 }});
9886 // Author handles
9887 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
9888
9889 // ── Export helpers ────────────────────────────────────────────────────────
9890 // Fetch one image from the server and return a data-URI Promise
9891 function mcFetchUri(path){{
9892 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
9893 return new Promise(function(res){{
9894 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
9895 }});
9896 }}).catch(function(){{return '';}});
9897 }}
9898 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
9899 function mcInlineImgs(html,cb){{
9900 var paths=[],seen={{}};
9901 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
9902 if(!paths.length){{cb(html);return;}}
9903 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
9904 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
9905 .catch(function(){{cb(html);}});
9906 }}
9907 // Capture full-page HTML with all table rows visible
9908 function mcRawHtml(pdfMode){{
9909 if(pdfMode)document.body.classList.add('pdf-mode');
9910 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
9911 var html=document.documentElement.outerHTML;
9912 perPage=s;currentPage=p;renderFilePage();
9913 if(pdfMode)document.body.classList.remove('pdf-mode');
9914 return html;
9915 }}
9916
9917 // HTML export (full page with inlined images)
9918 function mcDoHtml(btn,fname){{
9919 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
9920 mcInlineImgs(mcRawHtml(false),function(html){{
9921 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
9922 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9923 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9924 btn.disabled=false;btn.innerHTML=orig;
9925 }});
9926 }}
9927 // PDF export — comprehensive document-style report: full numbers, all sections
9928 function mcBuildPdfHtml(){{
9929 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9930 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
9931 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
9932 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>';}}
9933 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
9934 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
9935 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)));}}
9936 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
9937 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
9938 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
9939 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
9940 '.hdr{{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9941 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9942 '.title{{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}}'+
9943 '.proj{{font-size:12px;color:#99aabb;margin-top:3px;}}'+
9944 '.hr{{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}}'+
9945 '.body{{padding:18px 24px;}}'+
9946 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px;}}'+
9947 '.sc{{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}}'+
9948 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
9949 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
9950 '.sec{{margin-bottom:20px;}}'+
9951 '.sh{{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}}'+
9952 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
9953 'th{{background:#1a2035;color:#fff;padding:5px 8px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;}}'+
9954 'td{{border-bottom:1px solid #eee;padding:4px 8px;vertical-align:middle;}}'+
9955 'tr:nth-child(even) td{{background:#faf8f6;}}'+
9956 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:20px;}}';
9957 // ── Metric Progression ────────────────────────────────────────────────
9958 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
9959 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
9960 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>';
9961 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
9962 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
9963 var progRows=POINTS.map(function(pt,i){{
9964 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)));
9965 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
9966 '<td style="text-align:right">'+full(pt.code)+'</td>'+
9967 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
9968 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
9969 '<td style="text-align:right">'+full(pt.files)+'</td>';
9970 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
9971 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
9972 return r+'</tr>';
9973 }}).join('');
9974 // ── Scan-to-scan changes ──────────────────────────────────────────────
9975 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
9976 var prev=POINTS[i];
9977 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
9978 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
9979 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
9980 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
9981 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
9982 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
9983 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
9984 }}).join(''):'';
9985 // ── File matrix (top 50 by |total delta|) ────────────────────────────
9986 var fmSection='';
9987 if(FILES&&FILES.length){{
9988 // Hard cap on per-scan columns so the table never overflows the page width.
9989 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
9990 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
9991 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
9992 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
9993 fmHdr+='<th style="text-align:right">Total \u0394</th>';
9994 var fmRows=topFiles.map(function(f){{
9995 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
9996 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>';
9997 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
9998 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
9999 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>';
10000 }}).join('');
10001 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
10002 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
10003 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
10004 }}
10005 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
10006 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
10007 '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Multi-Scan Timeline</div><div class="proj">{project_label}</div></div>'+
10008 '<div class="hr">{n} scans<br><span style="color:#7a8b9c">'+commitsList+'</span><br>Generated: '+esc(now)+'</div></div>'+
10009 '<div class="body">'+
10010 '<div class="sg">'+
10011 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
10012 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
10013 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
10014 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
10015 (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>':
10016 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
10017 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
10018 '</div>'+
10019 '<div class="sec"><p class="sh">Metric Progression</p>'+
10020 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
10021 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
10022 '<table><thead><tr><th style="text-align:center">Scans</th>'+
10023 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
10024 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
10025 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
10026 fmSection+
10027 '</div>'+
10028 '<div class="ftr"><span>oxide-sloc v{version}</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div>'+
10029 '</body></html>';
10030 }}
10031 function mcDoPdf(btn){{
10032 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
10033 var html=mcBuildPdfHtml();
10034 fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:html,filename:mcExportName('pdf')}})}})
10035 .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
10036 .then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
10037 .catch(function(e){{alert('PDF export failed: '+e.message);}})
10038 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
10039 }}
10040
10041 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
10042 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
10043 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
10044 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
10045 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
10046 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
10047 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
10048 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
10049 if(location.protocol==='file:'){{
10050 [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';}}}} );
10051 [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';}}}} );
10052 }}
10053 }})();
10054 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
10055 (function(){{
10056 function $(id){{return document.getElementById(id);}}
10057 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10058 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
10059 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
10060 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
10061 function openModal(idx){{
10062 var ov=$('mc-modal-overlay');if(!ov)return;
10063 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
10064 if(idx<0||idx>=N)return;
10065 var pt=POINTS[idx];
10066 titleEl.textContent='Scan '+(idx+1);
10067 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
10068 subEl.textContent=lbl;
10069 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
10070 '<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>'+
10071 '<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>'+
10072 '<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>'+
10073 '<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>'+
10074 (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>':'')+
10075 (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>':'')+
10076 '</div></div>';
10077 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
10078 (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>':'')+
10079 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
10080 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
10081 (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>':'')+
10082 (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>':'')+
10083 (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>':'')+
10084 (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>':'')+
10085 '<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>'+
10086 '</div>';
10087 var dHtml='';
10088 if(idx>0){{
10089 var prev=POINTS[idx-1];
10090 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
10091 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
10092 '<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>'+
10093 '<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>'+
10094 '<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>'+
10095 '</div></div>';
10096 }}
10097 bodyEl.innerHTML=sHtml+iHtml+dHtml;
10098 ov.classList.add('open');document.body.style.overflow='hidden';
10099 }}
10100 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
10101 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
10102 document.addEventListener('click',function(e){{
10103 if(!e.target||!e.target.closest)return;
10104 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
10105 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
10106 var card=e.target.closest('.mc-card');
10107 if(!card)return;
10108 if(e.target.closest('a'))return;
10109 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
10110 var i=cards.indexOf(card);
10111 if(i>=0)openModal(i);
10112 }});
10113 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
10114 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
10115 var statTip=null;
10116 document.addEventListener('mousemove',function(e){{
10117 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
10118 if(!box){{if(statTip)statTip.style.display='none';return;}}
10119 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
10120 var tip=box.getAttribute('data-tip')||'';
10121 if(statTip.textContent!==tip)statTip.textContent=tip;
10122 statTip.style.display='block';
10123 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
10124 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
10125 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
10126 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
10127 }});
10128 (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');}})();
10129 }})();
10130 }})();
10131 </script>
10132 <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]';
10133 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;}}
10134 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>
10135 <!-- Scan card detail modal -->
10136 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
10137 <div class="mc-modal" id="mc-modal">
10138 <div class="mc-modal-head">
10139 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
10140 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
10141 </div>
10142 <div class="mc-modal-body" id="mc-modal-body"></div>
10143 </div>
10144 </div>
10145</body>
10146</html>"##,
10147 project_label = html_escape(project_label),
10148 n = n,
10149 scan_strip = scan_strip,
10150 mc_strip_class = mc_strip_class,
10151 metrics_thead = metrics_thead,
10152 metrics_tbody = metrics_tbody,
10153 file_col_headers = file_col_headers,
10154 total_files = total_files,
10155 files_modified = files_modified,
10156 files_added = files_added,
10157 files_removed = files_removed,
10158 files_unchanged = files_unchanged,
10159 points_json = points_json,
10160 file_matrix_json = file_matrix_json,
10161 nav_compare_active = nav_compare_active,
10162 version = version,
10163 csp_nonce = csp_nonce,
10164 scope_bar_html = scope_bar_html,
10165 scope_label = scope_label,
10166 )
10167}
10168
10169#[allow(clippy::too_many_lines)] async fn trend_report_handler(
10177 State(state): State<AppState>,
10178 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10179) -> Response {
10180 auto_scan_watched_dirs(&state).await;
10181
10182 let watched_dirs_list: Vec<String> = {
10183 let wd = state.watched_dirs.lock().await;
10184 wd.dirs.iter().map(|p| p.display().to_string()).collect()
10185 };
10186
10187 let roots: Vec<String> = {
10189 let reg = state.registry.lock().await;
10190 let mut seen = std::collections::BTreeSet::new();
10191 reg.entries
10192 .iter()
10193 .flat_map(|e| e.input_roots.iter().cloned())
10194 .filter(|r| seen.insert(r.clone()))
10195 .collect()
10196 };
10197
10198 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
10199 let nonce = &csp_nonce;
10200 let version = env!("CARGO_PKG_VERSION");
10201
10202 let watched_dirs_html: String = if state.server_mode {
10206 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()
10207 } else {
10208 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
10209 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
10210 .to_string()
10211 } else {
10212 watched_dirs_list
10213 .iter()
10214 .fold(String::new(), |mut s, d| {
10215 use std::fmt::Write as _;
10216 let escaped =
10217 d.replace('&', "&").replace('"', """).replace('<', "<");
10218 write!(
10219 s,
10220 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>"#
10221 ).expect("write to String is infallible");
10222 s
10223 })
10224 };
10225 format!(
10226 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>"#
10227 )
10228 };
10229
10230 let html = format!(
10231 r##"<!doctype html>
10232<html lang="en">
10233<head>
10234 <meta charset="utf-8" />
10235 <meta name="viewport" content="width=device-width, initial-scale=1" />
10236 <title>OxideSLOC | Trend Reports</title>
10237 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238 <style nonce="{nonce}">
10239 :root {{
10240 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10241 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10242 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
10243 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10244 --info-bg:#eef3ff; --info-text:#4467d8;
10245 }}
10246 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
10247 *{{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;}}
10248 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10249 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
10250 .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;}}
10251 @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));}}}}
10252 .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);}}
10253 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
10254 .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));}}
10255 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10257 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
10258 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
10259 @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; }} }}
10260 .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;}}
10261 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10262 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
10265 .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;}}
10266 .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;}}
10267 .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;}}
10268 .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;}}
10269 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10270 .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);}}
10271 .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;}}
10272 .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;}}
10273 .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;}}
10274 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10275 .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;}}
10276 .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);}}
10277 .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;}}
10278 .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;}}
10279 .tz-select:focus{{border-color:var(--oxide);}}
10280 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10281 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
10282 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
10283 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
10284 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
10285 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
10286 .trend-title-block{{flex:1;min-width:0;}}
10287 .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;}}
10288 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
10289 .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;}}
10290 .chart-select:focus{{border-color:var(--accent);}}
10291 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
10292 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
10293 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
10294 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
10295 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
10296 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
10297 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
10298 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
10299 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
10300 .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;}}
10301 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
10302 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
10303 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
10304 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
10305 .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;}}
10306 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
10307 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
10308 .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);}}
10309 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
10310 .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;}}
10311 .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;}}
10312 .data-table tr:last-child td{{border-bottom:none;}}
10313 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
10314 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
10315 .table-wrap{{width:100%;overflow-x:auto;}}
10316 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
10317 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
10318 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10319 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
10320 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
10321 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
10322 .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;}}
10323 .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;}}
10324 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10325 .pagination-info{{font-size:13px;color:var(--muted);}}
10326 .pagination-btns{{display:flex;gap:6px;}}
10327 .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;}}
10328 .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;}}
10329 #scan-history-table col:nth-child(1){{width:155px;}}
10330 #scan-history-table col:nth-child(2){{width:240px;}}
10331 #scan-history-table col:nth-child(3){{width:82px;}}
10332 #scan-history-table col:nth-child(4){{width:82px;}}
10333 #scan-history-table col:nth-child(5){{width:90px;}}
10334 #scan-history-table col:nth-child(6){{width:90px;}}
10335 #scan-history-table col:nth-child(7){{width:88px;}}
10336 #scan-history-table col:nth-child(8){{width:150px;}}
10337 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
10338 .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;}}
10339 .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;}}
10340 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
10341 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
10342 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
10343 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
10344 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
10345 .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;}}
10346 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
10347 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
10348 .watched-chip-rm:hover{{color:var(--oxide);}}
10349 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
10350 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
10351 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
10352 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
10353 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
10354 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
10355 a.run-link:hover{{text-decoration:underline;}}
10356 .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);}}
10357 .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);}}
10358 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
10359 .metric-num{{font-weight:700;color:var(--text);}}
10360 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
10361 .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;}}
10362 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10363 .btn.primary:hover{{opacity:.9;}}
10364 .rpt-btn{{min-width:58px;justify-content:center;}}
10365 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
10366 .report-cell{{overflow:visible!important;white-space:normal!important;}}
10367 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
10368 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
10369 .submod-details summary::-webkit-details-marker{{display:none;}}
10370 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
10371 .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;}}
10372 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
10373 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
10374 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
10375 .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;}}
10376 .export-btn:hover{{background:var(--line);}}
10377 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
10378 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10379 .site-footer a{{color:var(--muted);}}
10380 .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;}}
10381 .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;}}
10382 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
10383 </style>
10384</head>
10385<body>
10386 <div class="background-watermarks" aria-hidden="true">
10387 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10388 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10389 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10390 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10391 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10393 </div>
10394 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10395 <div class="top-nav">
10396 <div class="top-nav-inner">
10397 <a class="brand" href="/">
10398 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10399 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
10400 </a>
10401 <div class="nav-right">
10402 <a class="nav-pill" href="/">Home</a>
10403 <div class="nav-dropdown">
10404 <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>
10405 <div class="nav-dropdown-menu">
10406 <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>
10407 </div>
10408 </div>
10409 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10410 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10411 <div class="nav-dropdown">
10412 <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>
10413 <div class="nav-dropdown-menu">
10414 <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>
10415 </div>
10416 </div>
10417 <div class="server-status-wrap" id="server-status-wrap">
10418 <div class="nav-pill server-online-pill" id="server-status-pill">
10419 <span class="status-dot" id="status-dot"></span>
10420 <span id="server-status-label">Server</span>
10421 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10422 </div>
10423 <div class="server-status-tip">
10424 OxideSLOC is running — accessible on your network.
10425 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10426 </div>
10427 </div>
10428 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10429 <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>
10430 </button>
10431 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10432 <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>
10433 <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>
10434 </button>
10435 </div>
10436 </div>
10437 </div>
10438
10439 <div class="page">
10440 {watched_dirs_html}
10441 <div class="summary-strip" id="trend-stats"></div>
10442 <div class="panel">
10443 <div class="trend-header">
10444 <div class="trend-title-block">
10445 <h1>Trend Reports</h1>
10446 <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root, choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
10447 <span class="chart-hint-inline">
10448 <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>
10449 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
10450 </span>
10451 </div>
10452 <div class="chart-actions">
10453 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
10454 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
10455 Retention Policy
10456 </button>
10457 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
10458 <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>
10459 Clean up old runs
10460 </button>
10461 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
10462 <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>
10463 Export Excel
10464 </button>
10465 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
10466 <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>
10467 Export PNG
10468 </button>
10469 </div>
10470 </div>
10471
10472 <div class="controls-centered">
10473 <label>Project Root:
10474 <select class="chart-select" id="root-sel">
10475 <option value="">All projects</option>
10476 </select>
10477 </label>
10478 <label>Y Metric:
10479 <select class="chart-select" id="y-sel">
10480 <option value="code_lines">Code Lines</option>
10481 <option value="comment_lines">Comment Lines</option>
10482 <option value="blank_lines">Blank Lines</option>
10483 <option value="physical_lines">Physical Lines</option>
10484 <option value="files_analyzed">Files Analyzed</option>
10485 </select>
10486 </label>
10487 <label>X Axis:
10488 <select class="chart-select" id="x-sel">
10489 <option value="time">By Time</option>
10490 <option value="commit">By Commit</option>
10491 <option value="release">By Release</option>
10492 <option value="tag">Tagged Commits</option>
10493 </select>
10494 </label>
10495 <label id="submodule-label" style="display:none;">Submodule:
10496 <select class="chart-select" id="sub-sel">
10497 <option value="">All (project total)</option>
10498 </select>
10499 </label>
10500 <label>Chart Size:
10501 <select class="chart-select" id="scale-sel">
10502 <option value="0.75">Compact</option>
10503 <option value="1.2" selected>Normal</option>
10504 <option value="1.38">Large</option>
10505 </select>
10506 </label>
10507 </div>
10508
10509 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
10510 <div id="data-table-wrap" style="overflow-x:auto;"></div>
10511 </div>
10512 </div>
10513
10514 <script nonce="{nonce}">
10515 (function() {{
10516 // Theme persistence
10517 var b = document.body;
10518 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10519 var tgl = document.getElementById('theme-toggle');
10520 if (tgl) tgl.addEventListener('click', function() {{
10521 var d = b.classList.toggle('dark-theme');
10522 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10523 }});
10524
10525 // Watermark randomizer
10526 (function() {{
10527 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10528 if (!wms.length) return;
10529 var placed = [];
10530 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;}}
10531 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];}}
10532 var half=Math.floor(wms.length/2);
10533 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;}});
10534 }})();
10535
10536 // Code particles
10537 (function() {{
10538 var container = document.getElementById('code-particles');
10539 if (!container) return;
10540 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'];
10541 for (var i = 0; i < 38; i++) {{
10542 (function(idx) {{
10543 var el = document.createElement('span');
10544 el.className = 'code-particle';
10545 el.textContent = snippets[idx % snippets.length];
10546 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10547 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10548 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10549 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';
10550 container.appendChild(el);
10551 }})(i);
10552 }}
10553 }})();
10554
10555 // Watched folder picker
10556 (function() {{
10557 var btn = document.getElementById('add-watched-btn');
10558 if (!btn) return;
10559 btn.addEventListener('click', function() {{
10560 fetch('/pick-directory?kind=reports')
10561 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10562 .then(function(data) {{
10563 if (!data.cancelled && data.selected_path) {{
10564 var form = document.createElement('form');
10565 form.method = 'POST';
10566 form.action = '/watched-dirs/add';
10567 var ri = document.createElement('input');
10568 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10569 var fi = document.createElement('input');
10570 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10571 form.appendChild(ri); form.appendChild(fi);
10572 document.body.appendChild(form);
10573 form.submit();
10574 }}
10575 }})
10576 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10577 }});
10578 }})();
10579
10580 // Settings / color-scheme modal
10581 (function() {{
10582 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'}}];
10583 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);}});}}
10584 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10585 var btn=document.getElementById('settings-btn');if(!btn)return;
10586 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10587 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>';
10588 document.body.appendChild(m);
10589 var g=document.getElementById('scheme-grid');
10590 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);}});
10591 var cl=document.getElementById('settings-close');
10592 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);
10593 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');}});
10594 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10595 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10596 }})();
10597 }})();
10598
10599 var ROOTS = {roots_json};
10600 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
10601 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
10602 var allData = [];
10603
10604 // Populate root selector
10605 var rootSel = document.getElementById('root-sel');
10606 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
10607
10608 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();}}
10609 function fmtFull(n){{return Number(n).toLocaleString();}}
10610 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
10611
10612 // Tooltip
10613 var tt = document.createElement('div');
10614 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:9999;max-width:280px;color:var(--text);';
10615 document.body.appendChild(tt);
10616 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
10617 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';}}
10618 function hideTT(){{tt.style.display='none';}}
10619 window.addEventListener('blur',function(){{hideTT();}});
10620 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
10621
10622 function statExact(compact, full){{
10623 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
10624 }}
10625 function statVal(n){{
10626 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
10627 }}
10628
10629 function updateStats(data){{
10630 var statsEl=document.getElementById('trend-stats');
10631 if(!statsEl)return;
10632 if(!data||!data.length){{statsEl.innerHTML='';return;}}
10633 var yKey=document.getElementById('y-sel').value;
10634 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10635 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10636 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
10637 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
10638 var absDelta=Math.abs(delta);
10639 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
10640 var deltaExact=statExact(deltaCompact,deltaFull);
10641 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
10642 statsEl.innerHTML=
10643 '<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>'+
10644 '<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>'+
10645 '<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>'+
10646 '<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>';
10647 }}
10648
10649 var subSel = document.getElementById('sub-sel');
10650 var subLabel = document.getElementById('submodule-label');
10651
10652 function populateSubmodules(root){{
10653 if(!subSel||!subLabel)return;
10654 while(subSel.options.length>1)subSel.remove(1);
10655 subSel.value='';
10656 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
10657 fetch(url)
10658 .then(function(r){{return r.json();}})
10659 .then(function(subs){{
10660 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
10661 subs.forEach(function(s){{
10662 var o=document.createElement('option');
10663 o.value=s.name;
10664 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
10665 subSel.appendChild(o);
10666 }});
10667 subLabel.style.display='';
10668 }})
10669 .catch(function(){{subLabel.style.display='none';}});
10670 }}
10671
10672 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
10673
10674 function loadAndRender(){{
10675 var root = rootSel.value;
10676 var sub = subSel ? subSel.value : '';
10677 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
10678 document.getElementById('data-table-wrap').innerHTML='';
10679 var url = '/api/metrics/history?limit=100'
10680 + (root ? '&root='+encodeURIComponent(root) : '')
10681 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
10682 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
10683 allData = data;
10684 render(data);
10685 updateStats(data);
10686 }}).catch(function(){{
10687 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>';
10688 }});
10689 }}
10690
10691 function render(data){{
10692 var yKey = document.getElementById('y-sel').value;
10693 var xMode = document.getElementById('x-sel').value;
10694
10695 // Filter for tag/release mode
10696 var pts = data;
10697 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
10698
10699 // Sort oldest-first for the line chart
10700 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10701
10702 var wrap = document.getElementById('chart-wrap');
10703 if(!pts.length){{
10704 var emptyMsg = (xMode === 'tag')
10705 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
10706 : 'No scan data found for the selected filters.';
10707 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
10708 renderTable([]);
10709 return;
10710 }}
10711
10712 var scaleEl=document.getElementById('scale-sel');
10713 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
10714 var W=Math.round(900*sc),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;
10715 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
10716
10717 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10718
10719 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
10720 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>';
10721
10722 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
10723
10724 // Grid + Y axis ticks
10725 for(var ti=0;ti<=5;ti++){{
10726 var gy=PT+CH-Math.round(ti/5*CH);
10727 var gv=Math.round(ti/5*maxY);
10728 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
10729 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
10730 }}
10731
10732 // X axis labels (every N-th point to avoid crowding)
10733 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
10734 pts.forEach(function(d,i){{
10735 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10736 if(i%labelEvery===0||i===pts.length-1){{
10737 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)));
10738 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>';
10739 }}
10740 }});
10741
10742 // Axis label
10743 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
10744 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>';
10745 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>';
10746
10747 // Area fill + line path
10748 var pathD='';
10749 pts.forEach(function(d,i){{
10750 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10751 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10752 pathD+=(i===0?'M':'L')+x+','+y;
10753 }});
10754 if(pts.length>1){{
10755 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
10756 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
10757 }}
10758 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
10759
10760 // Data points (clickable) + permanent value labels
10761 var showLabels = pts.length <= 40;
10762 var labelEveryN = pts.length > 20 ? 2 : 1;
10763 pts.forEach(function(d,i){{
10764 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10765 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10766 var hasTags=d.tags&&d.tags.length>0;
10767 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
10768 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
10769 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+'"/>';
10770 if(showLabels && i%labelEveryN===0){{
10771 var lx=x, ly=y-r-5;
10772 svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmt(Number(d[yKey]))+'</text>';
10773 }}
10774 }});
10775
10776 svg+='</svg>';
10777 wrap.innerHTML=svg;
10778
10779 // Attach point tooltips
10780 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
10781 c.addEventListener('mouseover',function(e){{
10782 var d=pts[parseInt(this.dataset.idx)];
10783 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(''):'';
10784 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>':'';
10785 showTT(e,
10786 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
10787 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
10788 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
10789 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
10790 );
10791 this.setAttribute('r','8');
10792 }});
10793 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
10794 c.addEventListener('mousemove',moveTT);
10795 c.addEventListener('click',function(){{
10796 var d=pts[parseInt(this.dataset.idx)];
10797 if(d.html_url) window.open(d.html_url,'_blank');
10798 }});
10799 }});
10800
10801 renderTable(pts, yKey);
10802 }}
10803
10804 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
10805 var shProjFilter='', shBranchFilter='';
10806
10807 function fmtPST(isoStr){{
10808 if(!isoStr)return'';
10809 var d=new Date(isoStr);
10810 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
10811 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);}}
10812 function p(n){{return n<10?'0'+n:String(n);}}
10813 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++;}}}}
10814 var yr=d.getUTCFullYear();
10815 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
10816 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
10817 var isDST=d>=dstStart&&d<dstEnd;
10818 var off=isDST?-7*3600*1000:-8*3600*1000;
10819 var lbl=isDST?'PDT':'PST';
10820 var loc=new Date(d.getTime()+off);
10821 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
10822 }}
10823
10824 function getShRows(){{
10825 var proj=shProjFilter.toLowerCase().trim();
10826 var branch=shBranchFilter;
10827 return shData.filter(function(d){{
10828 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
10829 if(branch&&(d.branch||'')!==branch)return false;
10830 return true;
10831 }});
10832 }}
10833
10834 function renderShPage(){{
10835 var filtered=getShRows();
10836 if(shSortCol){{
10837 filtered.sort(function(a,b){{
10838 var va,vb;
10839 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
10840 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
10841 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
10842 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
10843 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
10844 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
10845 }});
10846 }}
10847 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
10848 shPage=Math.min(shPage,totalPages);
10849 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
10850 var visible=filtered.slice(start,end);
10851 var tbody=document.getElementById('sh-tbody');
10852 if(!tbody)return;
10853 tbody.innerHTML=visible.map(function(d){{
10854 var tsHtml=esc(fmtPST(d.timestamp));
10855 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>';
10856 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>';
10857 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
10858 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
10859 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
10860 var reportCell='';
10861 if(d.html_url){{
10862 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
10863 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>';}}
10864 reportCell+='</div>';
10865 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
10866 if(d.submodule_links&&d.submodule_links.length){{
10867 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
10868 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
10869 reportCell+='</div></details>';
10870 }}
10871 return '<tr>'
10872 +'<td>'+tsHtml+'</td>'
10873 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
10874 +'<td>'+runIdHtml+'</td>'
10875 +'<td>'+commitHtml+'</td>'
10876 +'<td>'+branchHtml+'</td>'
10877 +'<td>'+tags+'</td>'
10878 +'<td class="num">'+metricHtml+'</td>'
10879 +'<td class="report-cell">'+reportCell+'</td>'
10880 +'</tr>';
10881 }}).join('');
10882 var pgRange=document.getElementById('sh-pg-range');
10883 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
10884 var pgInfo=document.getElementById('sh-pg-info');
10885 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
10886 var pgBtns=document.getElementById('sh-pg-btns');
10887 if(pgBtns){{
10888 pgBtns.innerHTML='';
10889 function mkPgBtn(lbl,pg,active,disabled){{
10890 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
10891 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
10892 return b;
10893 }}
10894 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
10895 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
10896 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
10897 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
10898 }}
10899 }}
10900
10901 function wireTableBehavior(){{
10902 var pf=document.getElementById('sh-proj-filter');
10903 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
10904 var bf=document.getElementById('sh-branch-filter');
10905 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
10906 var rb=document.getElementById('sh-reset-btn');
10907 if(rb)rb.addEventListener('click',function(){{
10908 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
10909 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
10910 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
10911 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');}});
10912 renderShPage();
10913 }});
10914 var pps=document.getElementById('sh-per-page');
10915 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
10916 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
10917 ths.forEach(function(th){{
10918 th.addEventListener('click',function(e){{
10919 if(e.target.classList.contains('col-resize-handle'))return;
10920 var col=th.dataset.col;
10921 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
10922 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
10923 th.classList.add('sort-'+shSortOrder);
10924 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
10925 shPage=1;renderShPage();
10926 }});
10927 }});
10928 var table=document.getElementById('scan-history-table');
10929 if(!table)return;
10930 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
10931 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
10932 allThs.forEach(function(th,i){{
10933 var handle=th.querySelector('.col-resize-handle');
10934 if(!handle||!cols[i])return;
10935 var startX,startW;
10936 handle.addEventListener('mousedown',function(e){{
10937 e.stopPropagation();e.preventDefault();
10938 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
10939 handle.classList.add('dragging');
10940 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
10941 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
10942 document.addEventListener('mousemove',onMove);
10943 document.addEventListener('mouseup',onUp);
10944 }});
10945 }});
10946 }}
10947
10948 function renderTable(pts, yKey){{
10949 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
10950 var wrap=document.getElementById('data-table-wrap');
10951 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
10952 var yLabel=Y_LABELS[yKey]||yKey||'';
10953 shData=pts.slice().reverse();
10954 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
10955 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
10956 var branches={{}};
10957 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
10958 var branchOpts='<option value="">All branches</option>';
10959 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
10960 wrap.innerHTML=
10961 '<div class="chart-section-header">SCAN HISTORY</div>'+
10962 '<div class="filter-row">'+
10963 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
10964 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
10965 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
10966 '</div>'+
10967 '<div class="table-wrap">'+
10968 '<table id="scan-history-table" class="data-table">'+
10969 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
10970 '<thead><tr id="sh-thead">'+
10971 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
10972 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
10973 '<th>Run ID<div class="col-resize-handle"></div></th>'+
10974 '<th>Commit<div class="col-resize-handle"></div></th>'+
10975 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
10976 '<th>Tags<div class="col-resize-handle"></div></th>'+
10977 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
10978 '<th>Report<div class="col-resize-handle"></div></th>'+
10979 '</tr></thead>'+
10980 '<tbody id="sh-tbody"></tbody>'+
10981 '</table>'+
10982 '</div>'+
10983 '<div class="pagination">'+
10984 '<span class="pagination-info" id="sh-pg-info"></span>'+
10985 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
10986 '<div style="display:flex;align-items:center;gap:8px;">'+
10987 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
10988 '<select class="filter-select" id="sh-per-page">'+
10989 '<option value="10">10 per page</option>'+
10990 '<option value="25" selected>25 per page</option>'+
10991 '<option value="50">50 per page</option>'+
10992 '<option value="100">100 per page</option>'+
10993 '</select>'+
10994 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
10995 '</div>'+
10996 '</div>';
10997 wireTableBehavior();
10998 renderShPage();
10999 }}
11000
11001 function exportXLSX(){{
11002 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
11003 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
11004 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
11005 var s1R=sorted.map(function(d){{
11006 return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',d.commit||'',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||''];
11007 }});
11008 var pm={{}};
11009 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
11010 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'];
11011 var s2R=Object.keys(pm).map(function(p){{
11012 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11013 var lat=sc[sc.length-1],fst=sc[0];
11014 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
11015 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);
11016 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];
11017 }});
11018 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
11019 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
11020 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
11021 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
11022 }}
11023
11024 function buildXLSX(sheets,chartRows,chartRows2){{
11025 function s2b(s){{return new TextEncoder().encode(s);}}
11026 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
11027 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;}}
11028 function crc32(d){{
11029 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;}}}}
11030 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
11031 }}
11032 function buildSheet(hdr,rows,drawRid,withCtrl){{
11033 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
11034 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
11035 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
11036 x+='<row r="1">';
11037 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
11038 if(withCtrl){{x+='<c r="M1" t="inlineStr" s="1"><is><t>↓ Metric Selector</t></is></c><c r="N1" t="inlineStr"><is><t>Code Lines</t></is></c>';}}
11039 x+='</row>';
11040 rows.forEach(function(row,ri){{
11041 var rn=ri+2;
11042 x+='<row r="'+rn+'">';
11043 row.forEach(function(cell,ci){{
11044 var addr=col2l(ci+1)+rn;
11045 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
11046 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
11047 }});
11048 if(withCtrl){{x+='<c r="M'+rn+'"><f>CHOOSE(MATCH($N$1,{{"Code Lines","Comment Lines","Blank Lines","Physical Lines"}},0),F'+rn+',G'+rn+',H'+rn+',I'+rn+')</f><v>'+Number(row[5])+'</v></c>';}}
11049 x+='</row>';
11050 }});
11051 x+='</sheetData>';
11052 if(withCtrl){{x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="N1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines"</formula1></dataValidation></dataValidations>';}}
11053 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
11054 return x+'</worksheet>';
11055 }}
11056 function buildChartXML(rows){{
11057 var sn="'Scan History'";
11058 var nr=rows.length,er=nr+1;
11059 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'}}];
11060 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11061 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">';
11062 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11063 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11064 sd.forEach(function(s,i){{
11065 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11066 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>';
11067 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11068 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>';
11069 var dlp=(i===2)?'b':'t';
11070 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="'+dlp+'"/></c:dLbls>';
11071 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11072 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11073 x+='</c:strCache></c:strRef></c:cat>';
11074 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+'"/>';
11075 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11076 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11077 }});
11078 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
11079 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>';
11080 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>';
11081 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11082 return x;
11083 }}
11084 function buildChartXML2(rows){{
11085 var sn="'By Project'";
11086 var nr=rows.length,er=nr+1;
11087 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'}}];
11088 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11089 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">';
11090 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11091 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11092 sd.forEach(function(s,i){{
11093 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11094 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>';
11095 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11096 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>';
11097 var dlp=(i===2)?'b':'t';
11098 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="'+dlp+'"/></c:dLbls>';
11099 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11100 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11101 x+='</c:strCache></c:strRef></c:cat>';
11102 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+'"/>';
11103 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11104 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11105 }});
11106 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
11107 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>';
11108 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>';
11109 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11110 return x;
11111 }}
11112 function buildChartXML3(rows){{
11113 var sn="'Scan History'";
11114 var nr=rows.length,er=nr+1;
11115 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11116 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">';
11117 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
11118 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11119 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
11120 x+='<c:tx><c:strRef><c:f>'+sn+'!$N$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>';
11121 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
11122 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>';
11123 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>';
11124 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11125 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11126 x+='</c:strCache></c:strRef></c:cat>';
11127 x+='<c:val><c:numRef><c:f>'+sn+'!$M$2:$M$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
11128 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
11129 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11130 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
11131 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>';
11132 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>';
11133 x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Focus View — change N1 to switch metric</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>';
11134 return x;
11135 }}
11136 var hasChart=!!(chartRows&&chartRows.length);
11137 var nr=hasChart?chartRows.length:0;
11138 var hasChart2=!!(chartRows2&&chartRows2.length);
11139 var nr2=hasChart2?chartRows2.length:0;
11140 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>';
11141 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"/>';
11142 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
11143 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"/>';}}
11144 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"/>';}}
11145 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
11146 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>';
11147 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
11148 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"/>';}});
11149 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
11150 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>';
11151 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
11152 wbx+='</sheets></workbook>';
11153 var files=[
11154 {{name:'[Content_Types].xml',data:s2b(ct)}},
11155 {{name:'_rels/.rels',data:s2b(dotrels)}},
11156 {{name:'xl/workbook.xml',data:s2b(wbx)}},
11157 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
11158 {{name:'xl/styles.xml',data:s2b(styl)}}
11159 ];
11160 // Chart embedded directly in Scan History (sheet1); By Project is plain
11161 sheets.forEach(function(s,i){{
11162 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0)))}});
11163 }});
11164 if(hasChart){{
11165 var fromRow=nr+4,toRow=nr+24;
11166 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>')}});
11167 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11168 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">';
11169 drx+='<xdr:twoCellAnchor editAs="twoCell">';
11170 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>';
11171 drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11172 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11173 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11174 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11175 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11176 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
11177 var focRow=toRow+2,focRowEnd=toRow+22;
11178 drx+='<xdr:twoCellAnchor editAs="twoCell">';
11179 drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
11180 drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRowEnd+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11181 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11182 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11183 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11184 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
11185 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
11186 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
11187 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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
11188 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
11189 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
11190 }}
11191 if(hasChart2){{
11192 var fromRow2=nr2+4,toRow2=nr2+24;
11193 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>')}});
11194 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11195 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">';
11196 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
11197 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>';
11198 drx2+='<xdr:to><xdr:col>11</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
11199 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11200 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11201 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11202 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11203 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
11204 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
11205 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>')}});
11206 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
11207 }}
11208 var parts=[],offsets=[],total=0;
11209 files.forEach(function(f){{
11210 offsets.push(total);
11211 var nb=s2b(f.name),crc=crc32(f.data);
11212 var h=new DataView(new ArrayBuffer(30+nb.length));
11213 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
11214 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
11215 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
11216 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
11217 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
11218 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
11219 total+=30+nb.length+f.data.length;
11220 }});
11221 var cdStart=total;
11222 files.forEach(function(f,fi){{
11223 var nb=s2b(f.name),crc=crc32(f.data);
11224 var cd=new DataView(new ArrayBuffer(46+nb.length));
11225 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
11226 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
11227 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
11228 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
11229 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
11230 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
11231 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
11232 }});
11233 var cdSz=total-cdStart;
11234 var eocd=new DataView(new ArrayBuffer(22));
11235 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
11236 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
11237 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
11238 parts.push(new Uint8Array(eocd.buffer));
11239 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
11240 var out=new Uint8Array(sz);var off=0;
11241 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
11242 return out.buffer;
11243 }}
11244
11245 function exportPNG(){{
11246 var svgEl=document.querySelector('#chart-wrap svg');
11247 if(!svgEl){{alert('No chart to export yet.');return;}}
11248 var svgStr=new XMLSerializer().serializeToString(svgEl);
11249 var vb=svgEl.viewBox.baseVal,scale=2;
11250 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
11251 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
11252 var url=URL.createObjectURL(blob);
11253 var img=new Image();
11254 img.onload=function(){{
11255 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
11256 var ctx=canvas.getContext('2d');
11257 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
11258 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
11259 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
11260 URL.revokeObjectURL(url);
11261 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
11262 }};
11263 img.src=url;
11264 }}
11265
11266 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
11267 var el=document.getElementById(id);
11268 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
11269 }});
11270 rootSel.addEventListener('change',function(){{
11271 populateSubmodules(rootSel.value);
11272 loadAndRender();
11273 }});
11274 if(subSel)subSel.addEventListener('change',loadAndRender);
11275
11276 var xlsxBtn=document.getElementById('export-xlsx-btn');
11277 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
11278 var pngBtn=document.getElementById('export-png-btn');
11279 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
11280
11281 // ── Clean-up modal ───────────────────────────────────────────────────────
11282 (function(){{
11283 var triggerBtn=document.getElementById('cleanup-runs-btn');
11284 if(!triggerBtn)return;
11285 var modal=document.createElement('div');
11286 modal.style.cssText='display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;';
11287 modal.innerHTML='<div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">'
11288 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
11289 +'<p style="font-size:13px;color:var(--text);margin:0 0 14px;">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>'
11290 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
11291 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
11292 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:80px;padding:7px 10px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
11293 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
11294 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
11295 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
11296 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
11297 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
11298 +'</div></div>';
11299 document.body.appendChild(modal);
11300 triggerBtn.addEventListener('click',function(){{
11301 document.getElementById('cleanup-status').style.display='none';
11302 modal.style.display='flex';
11303 }});
11304 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
11305 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11306 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
11307 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
11308 var confirmBtn=this;
11309 confirmBtn.disabled=true;
11310 var status=document.getElementById('cleanup-status');
11311 status.style.display='block';
11312 status.style.background='#dbeafe';status.style.color='#1e40af';
11313 status.textContent='Deleting\u2026';
11314 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
11315 .then(function(resp){{
11316 return resp.json().then(function(d){{
11317 if(resp.ok){{
11318 status.style.background='#dcfce7';status.style.color='#166534';
11319 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
11320 setTimeout(function(){{window.location.reload();}},1500);
11321 }}else{{
11322 status.style.background='#fee2e2';status.style.color='#991b1b';
11323 status.textContent='Error: '+(d.error||'Unexpected error');
11324 confirmBtn.disabled=false;
11325 }}
11326 }});
11327 }})
11328 .catch(function(e){{
11329 status.style.background='#fee2e2';status.style.color='#991b1b';
11330 status.textContent='Network error: '+String(e);
11331 confirmBtn.disabled=false;
11332 }});
11333 }});
11334 }})();
11335
11336 // ── Retention policy panel ────────────────────────────────────────────────
11337 (function(){{
11338 var triggerBtn=document.getElementById('retention-policy-btn');
11339 if(!triggerBtn)return;
11340 var modal=document.createElement('div');
11341 modal.style.cssText='display:none;position:fixed;inset:0;z-index:9001;background:rgba(0,0,0,0.72);align-items:center;justify-content:center;';
11342 modal.innerHTML=''
11343 +'<div style="background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:36px 44px;max-width:580px;width:95%;box-shadow:0 24px 64px rgba(0,0,0,0.38);">'
11344 +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
11345 +'<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 — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
11346 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
11347 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
11348 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
11349 +'</div>'
11350 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
11351 +'<div>'
11352 +'<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>'
11353 +'<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;">'
11354 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
11355 +'</div>'
11356 +'<div>'
11357 +'<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>'
11358 +'<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;">'
11359 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
11360 +'</div>'
11361 +'</div>'
11362 +'<div style="margin-bottom:20px;">'
11363 +'<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>'
11364 +'<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;">'
11365 +'<option value="1">Every hour</option>'
11366 +'<option value="6">Every 6 hours</option>'
11367 +'<option value="12">Every 12 hours</option>'
11368 +'<option value="24" selected>Every 24 hours</option>'
11369 +'<option value="48">Every 2 days</option>'
11370 +'<option value="72">Every 3 days</option>'
11371 +'<option value="168">Every week</option>'
11372 +'</select>'
11373 +'</div>'
11374 +'<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;">—</div>'
11375 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
11376 +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
11377 +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
11378 +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
11379 +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
11380 +'</div>'
11381 +'</div>';
11382 document.body.appendChild(modal);
11383
11384 function rpShowStatus(msg,ok){{
11385 var s=document.getElementById('rp-status');
11386 s.style.display='block';
11387 s.style.background=ok?'#dcfce7':'#fee2e2';
11388 s.style.color=ok?'#166534':'#991b1b';
11389 s.textContent=msg;
11390 }}
11391 function fmtAgo(iso){{
11392 if(!iso)return'Never';
11393 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
11394 if(diff<60)return diff+'s ago';
11395 if(diff<3600)return Math.floor(diff/60)+'m ago';
11396 if(diff<86400)return Math.floor(diff/3600)+'h ago';
11397 return Math.floor(diff/86400)+'d ago';
11398 }}
11399 function loadPolicy(){{
11400 fetch('/api/cleanup-policy')
11401 .then(function(r){{return r.json();}})
11402 .then(function(d){{
11403 var p=d.policy;
11404 document.getElementById('rp-enabled').checked=p?p.enabled:false;
11405 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
11406 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
11407 var sel=document.getElementById('rp-interval');
11408 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;}}}}}}
11409 var lr=document.getElementById('rp-last-run');
11410 if(d.last_run_at){{
11411 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'):'');
11412 }}else{{
11413 lr.textContent='Auto-cleanup has not run yet.';
11414 }}
11415 }})
11416 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
11417 }}
11418
11419 triggerBtn.addEventListener('click',function(){{
11420 document.getElementById('rp-status').style.display='none';
11421 loadPolicy();
11422 modal.style.display='flex';
11423 }});
11424 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
11425 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11426
11427 document.getElementById('rp-save-btn').addEventListener('click',function(){{
11428 var enabled=document.getElementById('rp-enabled').checked;
11429 var ageVal=document.getElementById('rp-max-age').value.trim();
11430 var countVal=document.getElementById('rp-max-count').value.trim();
11431 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
11432 if(enabled&&!ageVal&&!countVal){{
11433 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
11434 return;
11435 }}
11436 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
11437 var saveBtn=document.getElementById('rp-save-btn');
11438 saveBtn.disabled=true;
11439 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
11440 .then(function(r){{
11441 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
11442 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
11443 }})
11444 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11445 .finally(function(){{saveBtn.disabled=false;}});
11446 }});
11447
11448 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
11449 var btn=this;
11450 btn.disabled=true;
11451 btn.textContent='Running\u2026';
11452 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
11453 .then(function(r){{return r.json();}})
11454 .then(function(d){{
11455 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
11456 loadPolicy();
11457 }})
11458 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11459 .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
11460 }});
11461 }})();
11462
11463 populateSubmodules(rootSel.value);
11464 loadAndRender();
11465
11466 (function randomizeWatermarks() {{
11467 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11468 if (!wms.length) return;
11469 var placed = [];
11470 function tooClose(top, left) {{
11471 for (var i = 0; i < placed.length; i++) {{
11472 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
11473 if (dt < 16 && dl < 12) return true;
11474 }}
11475 return false;
11476 }}
11477 function pick(leftBand) {{
11478 for (var attempt = 0; attempt < 50; attempt++) {{
11479 var top = Math.random() * 88 + 2;
11480 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11481 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
11482 }}
11483 var top = Math.random() * 88 + 2;
11484 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11485 placed.push([top, left]); return [top, left];
11486 }}
11487 var half = Math.floor(wms.length / 2);
11488 wms.forEach(function (img, i) {{
11489 var pos = pick(i < half);
11490 var size = Math.floor(Math.random() * 100 + 120);
11491 var rot = (Math.random() * 360).toFixed(1);
11492 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
11493 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;
11494 }});
11495 }})();
11496 (function spawnCodeParticles() {{
11497 var container = document.getElementById('code-particles');
11498 if (!container) return;
11499 var snippets = [
11500 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
11501 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
11502 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
11503 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
11504 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
11505 ];
11506 var count = 38;
11507 for (var i = 0; i < count; i++) {{
11508 (function(idx) {{
11509 var el = document.createElement('span');
11510 el.className = 'code-particle';
11511 el.textContent = snippets[idx % snippets.length];
11512 var left = Math.random() * 94 + 2;
11513 var top = Math.random() * 88 + 6;
11514 var dur = (Math.random() * 10 + 9).toFixed(1);
11515 var delay = (Math.random() * 18).toFixed(1);
11516 var rot = (Math.random() * 26 - 13).toFixed(1);
11517 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11518 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
11519 container.appendChild(el);
11520 }})(i);
11521 }}
11522 }})();
11523 </script>
11524 <footer class="site-footer">
11525 local code analysis - metrics, history and reports
11526 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
11527 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11528 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11529 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11530 · <a href="/api-docs" rel="noopener">REST API</a>
11531 </footer>
11532 <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} — 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>
11533</body>
11534</html>"##,
11535 );
11536
11537 Html(html).into_response()
11538}
11539
11540fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11541 use std::collections::HashMap;
11542 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
11543 return vec![];
11544 }
11545 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11546 for rec in per_file_records {
11547 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11548 let e = totals.entry(lang.display_name().to_string()).or_default();
11549 e.0 += u64::from(cov.lines_found);
11550 e.1 += u64::from(cov.lines_hit);
11551 }
11552 }
11553 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
11555 .into_iter()
11556 .filter(|(_, (found, _))| *found > 0)
11557 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11558 .collect();
11559 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11560 pairs
11561 .iter()
11562 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
11563 .collect()
11564}
11565
11566fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
11567 let mut high = 0u64;
11568 let mut mid = 0u64;
11569 let mut low = 0u64;
11570 for rec in per_file_records {
11571 if let Some(cov) = &rec.coverage {
11572 if cov.lines_found == 0 {
11573 continue;
11574 }
11575 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
11576 if pct >= 80.0 {
11577 high += 1;
11578 } else if pct >= 50.0 {
11579 mid += 1;
11580 } else {
11581 low += 1;
11582 }
11583 }
11584 }
11585 (high, mid, low)
11586}
11587
11588fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11589 let mut arr: Vec<serde_json::Value> = per_file_records
11590 .iter()
11591 .filter_map(|rec| {
11592 rec.coverage.as_ref().map(|cov| {
11593 let line_pct = if cov.lines_found > 0 {
11594 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
11595 / 10.0
11596 } else {
11597 0.0
11598 };
11599 let fn_pct = if cov.functions_found > 0 {
11600 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
11601 .round()
11602 / 10.0
11603 } else {
11604 -1.0
11605 };
11606 serde_json::json!({
11607 "rel": rec.relative_path,
11608 "lang": rec.language.map_or("?", |l| l.display_name()),
11609 "line_pct": line_pct,
11610 "fn_pct": fn_pct,
11611 "lhit": cov.lines_hit,
11612 "lfound": cov.lines_found,
11613 "fhit": cov.functions_hit,
11614 "ffound": cov.functions_found,
11615 })
11616 })
11617 })
11618 .collect();
11619 arr.sort_by(|a, b| {
11620 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
11621 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
11622 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
11623 });
11624 arr
11625}
11626
11627#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
11629 let mut langs: Vec<&sloc_core::LanguageSummary> = run
11630 .totals_by_language
11631 .iter()
11632 .filter(|l| l.test_count > 0)
11633 .collect();
11634 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11635 let lang_tests: Vec<serde_json::Value> = langs
11636 .iter()
11637 .map(|l| {
11638 let d = if l.code_lines > 0 {
11639 l.test_count as f64 / l.code_lines as f64 * 1000.0
11640 } else {
11641 0.0
11642 };
11643 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11644 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11645 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11646 })
11647 .collect();
11648 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
11649 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11650 let t = &run.summary_totals;
11651 let total_tests = t.test_count;
11652 let density = if t.code_lines > 0 {
11653 total_tests as f64 / t.code_lines as f64 * 1000.0
11654 } else {
11655 0.0
11656 };
11657 let most_tested = langs.first().map_or_else(
11658 || "\u{2014}".to_string(),
11659 |l| l.language.display_name().to_string(),
11660 );
11661 let test_files: u64 = run
11662 .per_file_records
11663 .iter()
11664 .filter(|f| f.raw_line_categories.test_count > 0)
11665 .count() as u64;
11666 let cov_line = if t.coverage_lines_found > 0 {
11667 format!(
11668 "{:.1}",
11669 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
11670 )
11671 } else {
11672 "0".to_string()
11673 };
11674 let cov_fn = if t.coverage_functions_found > 0 {
11675 format!(
11676 "{:.1}",
11677 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
11678 )
11679 } else {
11680 "0".to_string()
11681 };
11682 let cov_branch = if t.coverage_branches_found > 0 {
11683 format!(
11684 "{:.1}",
11685 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
11686 )
11687 } else {
11688 "0".to_string()
11689 };
11690 let has_cov = !cov_arr.is_empty();
11691 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
11692 serde_json::json!({
11693 "totals": {
11694 "test_count": total_tests,
11695 "assertions": t.test_assertion_count,
11696 "suites": t.test_suite_count,
11697 "test_files": test_files,
11698 "total_files": t.files_analyzed,
11699 "density_str": format!("{density:.1}"),
11700 "most_tested": most_tested,
11701 "langs_with_tests": langs.len(),
11702 "cov_line": cov_line,
11703 "cov_fn": cov_fn,
11704 "cov_branch": cov_branch,
11705 },
11706 "lang_tests": lang_tests,
11707 "cov": cov_arr,
11708 "cov_tiers": {"high": high, "mid": mid, "low": low},
11709 "file_cov": file_cov_arr,
11710 "has_coverage": has_cov,
11711 "submodules": {},
11712 })
11713}
11714
11715#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
11717 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
11718 .language_summaries
11719 .iter()
11720 .filter(|l| l.test_count > 0)
11721 .collect();
11722 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11723 let lang_tests: Vec<serde_json::Value> = langs
11724 .iter()
11725 .map(|l| {
11726 let d = if l.code_lines > 0 {
11727 l.test_count as f64 / l.code_lines as f64 * 1000.0
11728 } else {
11729 0.0
11730 };
11731 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11732 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11733 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11734 })
11735 .collect();
11736 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
11737 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
11738 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
11739 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
11740 let density = if sub.code_lines > 0 {
11741 total_tests as f64 / sub.code_lines as f64 * 1000.0
11742 } else {
11743 0.0
11744 };
11745 let most_tested = langs.first().map_or_else(
11746 || "\u{2014}".to_string(),
11747 |l| l.language.display_name().to_string(),
11748 );
11749 serde_json::json!({
11750 "totals": {
11751 "test_count": total_tests,
11752 "assertions": total_assertions,
11753 "suites": total_suites,
11754 "test_files": test_files_approx,
11755 "total_files": sub.files_analyzed,
11756 "density_str": format!("{density:.1}"),
11757 "most_tested": most_tested,
11758 "langs_with_tests": langs.len(),
11759 "cov_line": "0",
11760 "cov_fn": "0",
11761 "cov_branch": "0",
11762 },
11763 "lang_tests": lang_tests,
11764 "cov": [],
11765 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
11766 "has_coverage": false,
11767 })
11768}
11769
11770fn compute_cov_json_str(run: &AnalysisRun) -> String {
11771 use std::collections::HashMap;
11772 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11773 for rec in &run.per_file_records {
11774 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11775 let e = totals.entry(lang.display_name().to_string()).or_default();
11776 e.0 += u64::from(cov.lines_found);
11777 e.1 += u64::from(cov.lines_hit);
11778 }
11779 }
11780 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
11782 .into_iter()
11783 .filter(|(_, (found, _))| *found > 0)
11784 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11785 .collect();
11786 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11787 let parts: Vec<String> = pairs
11788 .iter()
11789 .map(|(lang, pct)| {
11790 let name = lang.replace('"', "\\\"");
11791 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
11792 })
11793 .collect();
11794 format!("[{}]", parts.join(","))
11795}
11796
11797fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
11798 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11799 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
11800}
11801
11802fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
11803 let mut entry = build_test_scope_entry(run);
11804 if !run.submodule_summaries.is_empty() {
11805 let subs: serde_json::Map<String, serde_json::Value> = run
11806 .submodule_summaries
11807 .iter()
11808 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
11809 .collect();
11810 entry["submodules"] = serde_json::Value::Object(subs);
11811 }
11812 entry
11813}
11814
11815fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
11816 let name = l.language.display_name().replace('"', "\\\"");
11817 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
11819 l.test_count as f64 / l.code_lines as f64 * 1000.0
11820 } else {
11821 0.0
11822 };
11823 format!(
11824 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
11825 name = name,
11826 t = l.test_count,
11827 a = l.test_assertion_count,
11828 s = l.test_suite_count,
11829 c = l.code_lines,
11830 d = density,
11831 f = l.files,
11832 )
11833}
11834
11835fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
11836 let Some(r) = run else {
11837 return "[]".to_string();
11838 };
11839 let mut langs: Vec<&sloc_core::LanguageSummary> = r
11840 .totals_by_language
11841 .iter()
11842 .filter(|l| l.test_count > 0)
11843 .collect();
11844 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11845 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
11846 format!("[{}]", parts.join(","))
11847}
11848
11849async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
11851 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
11852 scope_map.insert(
11853 "__all__".to_string(),
11854 latest_run.map_or_else(
11855 || {
11856 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
11857 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
11858 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
11859 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
11860 "has_coverage":false,"submodules":{}})
11861 },
11862 build_test_scope_entry,
11863 ),
11864 );
11865 let all_roots: Vec<String> = {
11866 let reg = state.registry.lock().await;
11867 let mut seen = std::collections::BTreeSet::new();
11868 reg.entries
11869 .iter()
11870 .flat_map(|e| e.input_roots.iter().cloned())
11871 .filter(|r| seen.insert(r.clone()))
11872 .collect()
11873 };
11874 for root in &all_roots {
11875 let json_path = {
11876 let reg = state.registry.lock().await;
11877 reg.entries
11878 .iter()
11879 .find(|e| e.input_roots.iter().any(|r| r == root))
11880 .and_then(|e| e.json_path.clone())
11881 };
11882 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
11883 let json_str = tokio::fs::read_to_string(&p).await.ok();
11884 json_str
11885 .as_deref()
11886 .and_then(|s| serde_json::from_str(s).ok())
11887 } else {
11888 None
11889 };
11890 if let Some(ref run) = run_for_root {
11891 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
11892 }
11893 }
11894 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
11895}
11896
11897#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
11901 State(state): State<AppState>,
11902 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11903) -> Response {
11904 auto_scan_watched_dirs(&state).await;
11905 let watched_dirs_list: Vec<String> = {
11906 let wd = state.watched_dirs.lock().await;
11907 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11908 };
11909 let latest_run: Option<AnalysisRun> = {
11910 let json_path = {
11911 let reg = state.registry.lock().await;
11912 reg.entries.first().and_then(|e| e.json_path.clone())
11913 };
11914 if let Some(p) = json_path {
11915 let json_str = tokio::fs::read_to_string(&p).await.ok();
11916 json_str
11917 .as_deref()
11918 .and_then(|s| serde_json::from_str(s).ok())
11919 } else {
11920 None
11921 }
11922 };
11923
11924 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
11926
11927 let cov_json: String = latest_run
11929 .as_ref()
11930 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11931 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
11932
11933 let _cov_tier_json: String = latest_run
11935 .as_ref()
11936 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11937 .map_or_else(
11938 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
11939 compute_cov_tier_json_str,
11940 );
11941
11942 let total_tests: u64 = latest_run
11943 .as_ref()
11944 .map_or(0, |r| r.summary_totals.test_count);
11945 let total_assertions: u64 = latest_run
11946 .as_ref()
11947 .map_or(0, |r| r.summary_totals.test_assertion_count);
11948 let total_suites: u64 = latest_run
11949 .as_ref()
11950 .map_or(0, |r| r.summary_totals.test_suite_count);
11951 let total_code: u64 = latest_run
11952 .as_ref()
11953 .map_or(0, |r| r.summary_totals.code_lines);
11954 let workspace_density: f64 = if total_code > 0 {
11955 total_tests as f64 / total_code as f64 * 1000.0
11956 } else {
11957 0.0
11958 };
11959 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
11960 r.totals_by_language
11961 .iter()
11962 .filter(|l| l.test_count > 0)
11963 .count()
11964 });
11965 let most_tested: String = latest_run
11966 .as_ref()
11967 .and_then(|r| {
11968 r.totals_by_language
11969 .iter()
11970 .filter(|l| l.test_count > 0)
11971 .max_by_key(|l| l.test_count)
11972 })
11973 .map_or_else(
11974 || "\u{2014}".to_string(),
11975 |l| l.language.display_name().to_string(),
11976 );
11977 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
11978 r.per_file_records
11979 .iter()
11980 .filter(|f| f.raw_line_categories.test_count > 0)
11981 .count() as u64
11982 });
11983 let total_files_analyzed: u64 = latest_run
11984 .as_ref()
11985 .map_or(0, |r| r.summary_totals.files_analyzed);
11986 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
11987
11988 let cov_line_pct_str: String = latest_run
11990 .as_ref()
11991 .filter(|r| r.summary_totals.coverage_lines_found > 0)
11992 .map_or_else(
11993 || "0".to_string(),
11994 |r| {
11995 format!(
11996 "{:.1}",
11997 r.summary_totals.coverage_lines_hit as f64
11998 / r.summary_totals.coverage_lines_found as f64
11999 * 100.0
12000 )
12001 },
12002 );
12003 let cov_fn_pct_str: String = latest_run
12004 .as_ref()
12005 .filter(|r| r.summary_totals.coverage_functions_found > 0)
12006 .map_or_else(
12007 || "0".to_string(),
12008 |r| {
12009 format!(
12010 "{:.1}",
12011 r.summary_totals.coverage_functions_hit as f64
12012 / r.summary_totals.coverage_functions_found as f64
12013 * 100.0
12014 )
12015 },
12016 );
12017 let cov_branch_pct_str: String = latest_run
12018 .as_ref()
12019 .filter(|r| r.summary_totals.coverage_branches_found > 0)
12020 .map_or_else(
12021 || "0".to_string(),
12022 |r| {
12023 format!(
12024 "{:.1}",
12025 r.summary_totals.coverage_branches_hit as f64
12026 / r.summary_totals.coverage_branches_found as f64
12027 * 100.0
12028 )
12029 },
12030 );
12031
12032 let cov_no_data_notice = if has_coverage {
12033 String::new()
12034 } else {
12035 String::from(
12036 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
12037<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>
12038<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
12039 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
12040 <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>
12041 <span style="color:var(--muted);font-size:12px;">·</span>
12042 <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>
12043 <span style="color:var(--muted);font-size:12px;">·</span>
12044 <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>
12045</div>
12046<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
12047</div>"#,
12048 )
12049 };
12050
12051 let workspace_density_str = format!("{workspace_density:.1}");
12052 let nonce = &csp_nonce;
12053 let version = env!("CARGO_PKG_VERSION");
12054
12055 let watched_dirs_html: String = if state.server_mode {
12058 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()
12059 } else {
12060 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12061 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12062 .to_string()
12063 } else {
12064 watched_dirs_list
12065 .iter()
12066 .fold(String::new(), |mut s, d| {
12067 use std::fmt::Write as _;
12068 let escaped =
12069 d.replace('&', "&").replace('"', """).replace('<', "<");
12070 write!(
12071 s,
12072 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>"#
12073 ).expect("write to String is infallible");
12074 s
12075 })
12076 };
12077 format!(
12078 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>"#
12079 )
12080 };
12081
12082 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
12084
12085 let html = format!(
12086 r#"<!doctype html>
12087<html lang="en">
12088<head>
12089 <meta charset="utf-8" />
12090 <meta name="viewport" content="width=device-width, initial-scale=1" />
12091 <title>OxideSLOC | Test Metrics</title>
12092 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12093 <style nonce="{nonce}">
12094 :root {{
12095 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12096 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12097 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12098 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12099 --info-bg:#eef3ff; --info-text:#4467d8;
12100 }}
12101 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12102 *{{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;}}
12103 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12104 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12105 .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;}}
12106 @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));}}}}
12107 .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);}}
12108 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12109 .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));}}
12110 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12111 .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;}}
12112 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12113 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12114 @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; }} }}
12115 .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;}}
12116 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12117 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12118 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12119 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12120 .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;}}
12121 .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;}}
12122 .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;}}
12123 .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;}}
12124 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12125 .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);}}
12126 .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;}}
12127 .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;}}
12128 .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;}}
12129 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12130 .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;}}
12131 .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);}}
12132 .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;}}
12133 .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;}}
12134 .tz-select:focus{{border-color:var(--oxide);}}
12135 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12136 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12137 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12138 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12139 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12140 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12141 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12142 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
12143 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12144 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12145 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12146 .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;}}
12147 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;}}
12148 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12149 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
12150 .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);}}
12151 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
12152 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
12153 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
12154 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
12155 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
12156 .chart-canvas-wrap{{position:relative;height:280px;}}
12157 .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;}}
12158 .chart-no-data svg{{opacity:0.35;}}
12159 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
12160 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
12161 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
12162 .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;}}
12163 .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;}}
12164 .data-table tr:last-child td{{border-bottom:none;}}
12165 .data-table tbody tr:hover td{{background:var(--surface-2);}}
12166 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
12167 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
12168 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
12169 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
12170 .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 .2s ease,box-shadow .2s ease;min-width:0;}}
12171 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
12172 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
12173 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
12174 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
12175 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
12176 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
12177 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
12178 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
12179 .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;}}
12180 .chart-select:focus{{border-color:var(--accent);}}
12181 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12182 .trend-canvas-wrap{{position:relative;height:260px;}}
12183 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12184 .site-footer a{{color:var(--muted);}}
12185 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
12186 .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;}}
12187 .btn:hover{{background:var(--surface-2);}}
12188 .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;}}
12189 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12190 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
12191 .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;}}
12192 .scope-sel:focus{{border-color:var(--accent);}}
12193 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
12194 .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;}}
12195 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12196 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12197 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12198 .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;}}
12199 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12200 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12201 .watched-chip-rm:hover{{color:var(--oxide);}}
12202 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12203 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12204 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12205 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12206 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
12207 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
12208 .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;}}
12209 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
12210 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
12211 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
12212 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
12213 .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;}}
12214 .cov-file-search:focus{{border-color:var(--accent);}}
12215 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
12216 .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;}}
12217 body.dark-theme .cov-file-search{{background:var(--surface);}}
12218 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
12219 .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;}}
12220 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12221 .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;}}
12222 .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);}}
12223 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
12224 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
12225 .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;}}
12226 .chart-modal-close:hover{{opacity:.7;}}
12227 body.dark-theme .chart-modal{{background:var(--surface);}}
12228 </style>
12229</head>
12230<body>
12231 <div class="background-watermarks" aria-hidden="true">
12232 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12233 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12234 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12235 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12236 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12237 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12238 </div>
12239 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12240 <div class="top-nav">
12241 <div class="top-nav-inner">
12242 <a class="brand" href="/">
12243 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12244 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
12245 </a>
12246 <div class="nav-right">
12247 <a class="nav-pill" href="/">Home</a>
12248 <div class="nav-dropdown">
12249 <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>
12250 <div class="nav-dropdown-menu">
12251 <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>
12252 </div>
12253 </div>
12254 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12255 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
12256 <div class="nav-dropdown">
12257 <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>
12258 <div class="nav-dropdown-menu">
12259 <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>
12260 </div>
12261 </div>
12262 <div class="server-status-wrap" id="server-status-wrap">
12263 <div class="nav-pill server-online-pill" id="server-status-pill">
12264 <span class="status-dot" id="status-dot"></span>
12265 <span id="server-status-label">Server</span>
12266 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12267 </div>
12268 <div class="server-status-tip">
12269 OxideSLOC is running — accessible on your network.
12270 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12271 </div>
12272 </div>
12273 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12274 <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>
12275 </button>
12276 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12277 <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>
12278 <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>
12279 </button>
12280 </div>
12281 </div>
12282 </div>
12283
12284 <div class="page">
12285 {watched_dirs_html}
12286 <div class="scope-bar">
12287 <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>
12288 <span class="scope-label">Scope</span>
12289 <div class="scope-sel-wrap">
12290 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
12291 <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);">
12292 <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>
12293 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
12294 </div>
12295 </div>
12296 </div>
12297 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12298 <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>
12299 <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>
12300 <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>
12301 <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>
12302 </div>
12303 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12304 <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>
12305 <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>
12306 <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>
12307 <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>
12308 </div>
12309
12310 <div class="panel" id="viz-panel">
12311 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
12312
12313 <div class="chart-box" style="margin-bottom:18px;">
12314 <div class="chart-box-header">
12315 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
12316 <div style="display:flex;gap:8px;align-items:center;">
12317 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
12318 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12319 </div>
12320 </div>
12321 <p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare all scans side-by-side.</p>
12322 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
12323 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
12324 </div>
12325
12326 <div class="chart-row">
12327 <div class="chart-box">
12328 <div class="chart-box-header">
12329 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
12330 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12331 </div>
12332 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
12333 <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>
12334 </div>
12335 <div class="chart-box">
12336 <div class="chart-box-header">
12337 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
12338 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12339 </div>
12340 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
12341 <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>
12342 </div>
12343 </div>
12344
12345 <div class="chart-row">
12346 <div class="chart-box">
12347 <div class="chart-box-header">
12348 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
12349 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12350 </div>
12351 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
12352 <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>
12353 </div>
12354 <div class="chart-box" id="suites-chart-box">
12355 <div class="chart-box-header">
12356 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
12357 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12358 </div>
12359 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
12360 <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>
12361 </div>
12362 </div>
12363
12364 <div class="chart-row">
12365 <div class="chart-box">
12366 <div class="chart-box-title">Test Files Breakdown</div>
12367 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
12368 <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>
12369 </div>
12370 <div class="chart-box">
12371 <div class="chart-box-title">Test Composition</div>
12372 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
12373 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
12374 <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>
12375 </div>
12376 </div>
12377 </div>
12378
12379 <div class="panel">
12380 <h1>Test Metrics</h1>
12381 <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>
12382
12383 <div class="section-header">Language Breakdown</div>
12384 {cov_no_data_notice}
12385 <div style="overflow-x:auto;">
12386 <table class="data-table" id="lang-table">
12387 <thead><tr>
12388 <th>Language</th>
12389 <th class="num">Test Fns</th>
12390 <th class="num">Assertions</th>
12391 <th class="num">Suites</th>
12392 <th class="num">Code Lines</th>
12393 <th class="num">Files</th>
12394 <th class="num">Density / 1K</th>
12395 <th>Relative Density</th>
12396 </tr></thead>
12397 <tbody id="lang-tbody"></tbody>
12398 </table>
12399 </div>
12400 </div>
12401
12402 <div class="panel" id="cov-panel" style="display:none;">
12403 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
12404 <div class="cov-gauge-row" id="cov-gauges">
12405 <div class="cov-gauge-card">
12406 <div class="cov-gauge-label">Line Coverage</div>
12407 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
12408 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
12409 <div class="cov-gauge-sub">Lines hit / instrumented</div>
12410 </div>
12411 <div class="cov-gauge-card">
12412 <div class="cov-gauge-label">Function Coverage</div>
12413 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
12414 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
12415 <div class="cov-gauge-sub">Functions hit / found</div>
12416 </div>
12417 <div class="cov-gauge-card">
12418 <div class="cov-gauge-label">Branch Coverage</div>
12419 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
12420 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
12421 <div class="cov-gauge-sub">Branches hit / found</div>
12422 </div>
12423 </div>
12424 <div class="chart-row">
12425 <div class="chart-box">
12426 <div class="chart-box-title">Line Coverage % by Language</div>
12427 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
12428 </div>
12429 <div class="chart-box">
12430 <div class="chart-box-title">Coverage Tier Distribution</div>
12431 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
12432 </div>
12433 </div>
12434
12435 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
12436 <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>
12437 <div class="cov-file-toolbar">
12438 <div class="cov-filter-tabs" id="cov-filter-tabs">
12439 <button class="cov-tab active" data-tier="all">All</button>
12440 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
12441 <button class="cov-tab" data-tier="low">Low (<50%)</button>
12442 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
12443 <button class="cov-tab" data-tier="high">High (≥80%)</button>
12444 </div>
12445 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
12446 </div>
12447 <div style="overflow-x:auto;">
12448 <table class="data-table" id="cov-file-table">
12449 <thead><tr>
12450 <th>File</th>
12451 <th>Lang</th>
12452 <th class="num">Line %</th>
12453 <th class="num">Lines Hit / Found</th>
12454 <th class="num">Fn %</th>
12455 <th class="num">Fns Hit / Found</th>
12456 </tr></thead>
12457 <tbody id="cov-file-tbody"></tbody>
12458 </table>
12459 </div>
12460 <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>
12461 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
12462 </div>
12463
12464 </div>
12465
12466 <footer class="site-footer">
12467 local code analysis - metrics, history and reports
12468 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
12469 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12470 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12471 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12472 · <a href="/api-docs" rel="noopener">REST API</a>
12473 </footer>
12474
12475 <script nonce="{nonce}">
12476 (function() {{
12477 // Theme
12478 var b = document.body;
12479 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12480 var tgl = document.getElementById('theme-toggle');
12481 if (tgl) tgl.addEventListener('click', function() {{
12482 var d = b.classList.toggle('dark-theme');
12483 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12484 }});
12485
12486 // Watermarks
12487 (function() {{
12488 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12489 if (!wms.length) return;
12490 var placed = [];
12491 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;}}
12492 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];}}
12493 var half=Math.floor(wms.length/2);
12494 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;}});
12495 }})();
12496
12497 // Code particles
12498 (function() {{
12499 var container = document.getElementById('code-particles');
12500 if (!container) return;
12501 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
12502 for (var i = 0; i < 36; i++) {{
12503 (function(idx) {{
12504 var el = document.createElement('span');
12505 el.className = 'code-particle';
12506 el.textContent = snippets[idx % snippets.length];
12507 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12508 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12509 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12510 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';
12511 container.appendChild(el);
12512 }})(i);
12513 }}
12514 }})();
12515
12516 // Settings modal
12517 (function() {{
12518 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'}}];
12519 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);}});}}
12520 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12521 var btn=document.getElementById('settings-btn');if(!btn)return;
12522 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12523 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>';
12524 document.body.appendChild(m);
12525 var g=document.getElementById('scheme-grid');
12526 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);}});
12527 var cl=document.getElementById('settings-close');
12528 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');}});
12529 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12530 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12531 }})();
12532
12533 // Watched folder picker
12534 (function() {{
12535 var btn = document.getElementById('add-watched-btn');
12536 if (!btn) return;
12537 btn.addEventListener('click', function() {{
12538 fetch('/pick-directory?kind=reports')
12539 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12540 .then(function(data) {{
12541 if (!data.cancelled && data.selected_path) {{
12542 var form = document.createElement('form');
12543 form.method = 'POST';
12544 form.action = '/watched-dirs/add';
12545 var ri = document.createElement('input');
12546 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12547 var fi = document.createElement('input');
12548 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12549 form.appendChild(ri); form.appendChild(fi);
12550 document.body.appendChild(form);
12551 form.submit();
12552 }}
12553 }})
12554 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12555 }});
12556 }})();
12557 }})();
12558 </script>
12559
12560 <script src="/static/chart.js" nonce="{nonce}"></script>
12561 <script nonce="{nonce}">
12562 (function() {{
12563 var SCOPE_DATA = {scope_data_json};
12564 var currentRoot = '__all__';
12565 var currentSub = '';
12566 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
12567 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
12568 var ALL_CHARTS = [];
12569 var currentLangTests = [];
12570 var currentTrendPts = [];
12571
12572 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();}}
12573 function fmtFull(n){{return Number(n).toLocaleString();}}
12574 function isDark(){{return document.body.classList.contains('dark-theme');}}
12575 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
12576 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
12577 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
12578
12579 function makeDlPlugin(fmtFn, anchor) {{
12580 return {{
12581 afterDatasetsDraw: function(chart) {{
12582 var ctx = chart.ctx;
12583 var tc = txtClr();
12584 chart.data.datasets.forEach(function(ds, di) {{
12585 var meta = chart.getDatasetMeta(di);
12586 meta.data.forEach(function(el, idx) {{
12587 var label = fmtFn(ds.data[idx], di, idx);
12588 if (label == null || label === '') return;
12589 ctx.save();
12590 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
12591 ctx.fillStyle = tc;
12592 if (anchor === 'top') {{
12593 ctx.textAlign = 'center';
12594 ctx.textBaseline = 'bottom';
12595 ctx.fillText(String(label), el.x, el.y - 5);
12596 }} else {{
12597 ctx.textAlign = 'left';
12598 ctx.textBaseline = 'middle';
12599 ctx.fillText(String(label), el.x + 5, el.y);
12600 }}
12601 ctx.restore();
12602 }});
12603 }});
12604 }}
12605 }};
12606 }}
12607
12608 function makeTmOverlay(title, subtitle, h) {{
12609 var overlay = document.createElement('div');
12610 overlay.className = 'chart-modal-overlay';
12611 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
12612 var ch = Math.min(h || 560, maxH);
12613 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
12614 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>';
12615 document.body.appendChild(overlay);
12616 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
12617 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
12618 return document.getElementById('tm-modal-canvas');
12619 }}
12620
12621 function getDataset() {{
12622 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
12623 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
12624 return r;
12625 }}
12626 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
12627
12628 function showNoData(id, show) {{
12629 var el = document.getElementById(id);
12630 if (!el) return;
12631 var wrap = el.previousElementSibling;
12632 el.style.display = show ? '' : 'none';
12633 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
12634 }}
12635
12636 function renderTestCharts(D) {{
12637 currentLangTests = D || [];
12638 testsChart = destroyChart(testsChart);
12639 densityChart = destroyChart(densityChart);
12640 if (!D || !D.length) {{
12641 showNoData('no-data-tests', true);
12642 showNoData('no-data-density', true);
12643 return;
12644 }}
12645 showNoData('no-data-tests', false);
12646 showNoData('no-data-density', false);
12647 var top15 = D.slice(0, 15);
12648 var canvas1 = document.getElementById('canvas-tests');
12649 if (canvas1) {{
12650 testsChart = new Chart(canvas1, {{
12651 type: 'bar',
12652 data: {{
12653 labels: top15.map(function(d){{ return d.lang; }}),
12654 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
12655 }},
12656 options: {{
12657 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12658 layout: {{ padding: {{ right: 64 }} }},
12659 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12660 scales: {{
12661 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12662 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12663 }}
12664 }},
12665 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12666 }});
12667 ALL_CHARTS.push(testsChart);
12668 }}
12669 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
12670 var canvas2 = document.getElementById('canvas-density');
12671 if (canvas2) {{
12672 densityChart = new Chart(canvas2, {{
12673 type: 'bar',
12674 data: {{
12675 labels: topD.map(function(d){{ return d.lang; }}),
12676 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 }}]
12677 }},
12678 options: {{
12679 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12680 layout: {{ padding: {{ right: 64 }} }},
12681 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
12682 scales: {{
12683 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
12684 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12685 }}
12686 }},
12687 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
12688 }});
12689 ALL_CHARTS.push(densityChart);
12690 }}
12691 }}
12692
12693 function renderAssertionsChart(D) {{
12694 assertionsChart = destroyChart(assertionsChart);
12695 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
12696 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
12697 var canvas = document.getElementById('canvas-assertions');
12698 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
12699 showNoData('no-data-assertions', false);
12700 assertionsChart = new Chart(canvas, {{
12701 type: 'bar',
12702 data: {{
12703 labels: top15.map(function(d){{ return d.lang; }}),
12704 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
12705 }},
12706 options: {{
12707 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12708 layout: {{ padding: {{ right: 64 }} }},
12709 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12710 scales: {{
12711 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12712 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12713 }}
12714 }},
12715 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12716 }});
12717 ALL_CHARTS.push(assertionsChart);
12718 }}
12719
12720 function renderSuitesChart(D) {{
12721 suitesChart = destroyChart(suitesChart);
12722 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
12723 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
12724 var canvas = document.getElementById('canvas-suites');
12725 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
12726 showNoData('no-data-suites', false);
12727 suitesChart = new Chart(canvas, {{
12728 type: 'bar',
12729 data: {{
12730 labels: top15.map(function(d){{ return d.lang; }}),
12731 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 }}]
12732 }},
12733 options: {{
12734 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12735 layout: {{ padding: {{ right: 64 }} }},
12736 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12737 scales: {{
12738 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12739 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12740 }}
12741 }},
12742 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12743 }});
12744 ALL_CHARTS.push(suitesChart);
12745 }}
12746
12747 function renderFilesChart(totals) {{
12748 filesChart = destroyChart(filesChart);
12749 var canvas = document.getElementById('canvas-files');
12750 if (!canvas) return;
12751 var testF = totals.test_files || 0;
12752 var totalF = totals.total_files || 0;
12753 var nonTest = Math.max(0, totalF - testF);
12754 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
12755 showNoData('no-data-files', false);
12756 var dark = isDark();
12757 filesChart = new Chart(canvas, {{
12758 type: 'doughnut',
12759 data: {{
12760 labels: ['Test Files', 'Non-Test Files'],
12761 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
12762 }},
12763 options: {{
12764 responsive: true, maintainAspectRatio: false, cutout: '62%',
12765 plugins: {{
12766 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12767 tooltip: {{ callbacks: {{ label: function(ctx) {{
12768 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
12769 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
12770 }} }} }}
12771 }}
12772 }}
12773 }});
12774 ALL_CHARTS.push(filesChart);
12775 }}
12776
12777 function renderCompositionChart(totals) {{
12778 compositionChart = destroyChart(compositionChart);
12779 var canvas = document.getElementById('canvas-composition');
12780 if (!canvas) return;
12781 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
12782 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
12783 showNoData('no-data-composition', false);
12784 compositionChart = new Chart(canvas, {{
12785 type: 'bar',
12786 data: {{
12787 labels: ['Test Functions', 'Assertions', 'Test Suites'],
12788 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
12789 }},
12790 options: {{
12791 responsive: true, maintainAspectRatio: false,
12792 layout: {{ padding: {{ top: 22 }} }},
12793 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
12794 scales: {{
12795 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
12796 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
12797 }}
12798 }},
12799 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
12800 }});
12801 ALL_CHARTS.push(compositionChart);
12802 }}
12803
12804 function renderCovCharts(covD, tiers) {{
12805 covChart = destroyChart(covChart);
12806 tierChart = destroyChart(tierChart);
12807 var covCanvas = document.getElementById('canvas-cov');
12808 if (covCanvas && covD && covD.length) {{
12809 covChart = new Chart(covCanvas, {{
12810 type: 'bar',
12811 data: {{
12812 labels: covD.map(function(d){{ return d.lang; }}),
12813 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 }}]
12814 }},
12815 options: {{
12816 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12817 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
12818 scales: {{
12819 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
12820 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12821 }}
12822 }}
12823 }});
12824 ALL_CHARTS.push(covChart);
12825 }}
12826 var tierCanvas = document.getElementById('canvas-cov-tiers');
12827 if (tierCanvas && tiers) {{
12828 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
12829 tierChart = new Chart(tierCanvas, {{
12830 type: 'doughnut',
12831 data: {{
12832 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
12833 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
12834 }},
12835 options: {{
12836 responsive: true, maintainAspectRatio: false, cutout: '62%',
12837 plugins: {{
12838 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12839 tooltip: {{ callbacks: {{ label: function(ctx) {{
12840 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
12841 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
12842 }} }} }}
12843 }}
12844 }}
12845 }});
12846 ALL_CHARTS.push(tierChart);
12847 }}
12848 }}
12849
12850 function buildLangTable(D) {{
12851 var tbody = document.getElementById('lang-tbody');
12852 if (!tbody) return;
12853 if (!D || !D.length) {{
12854 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>';
12855 return;
12856 }}
12857 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
12858 tbody.innerHTML = D.map(function(d) {{
12859 var barW = Math.round(d.density / maxDensity * 120);
12860 return '<tr>' +
12861 '<td><strong>' + d.lang + '</strong></td>' +
12862 '<td class="num">' + fmt(d.tests) + '</td>' +
12863 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
12864 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
12865 '<td class="num">' + fmt(d.code) + '</td>' +
12866 '<td class="num">' + fmt(d.files) + '</td>' +
12867 '<td class="num">' + d.density.toFixed(2) + '</td>' +
12868 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
12869 '</tr>';
12870 }}).join('');
12871 }}
12872
12873 var covFileData = [];
12874 var covFileTier = 'all';
12875 var covFileSearch = '';
12876
12877 function pctBadge(pct) {{
12878 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
12879 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
12880 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
12881 }}
12882
12883 function buildCovFileTable() {{
12884 var tbody = document.getElementById('cov-file-tbody');
12885 var empty = document.getElementById('cov-file-empty');
12886 var count = document.getElementById('cov-file-count');
12887 if (!tbody) return;
12888 var srch = covFileSearch.toLowerCase();
12889 var filtered = covFileData.filter(function(f) {{
12890 if (covFileTier === 'zero' && f.line_pct > 0) return false;
12891 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
12892 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
12893 if (covFileTier === 'high' && f.line_pct < 80) return false;
12894 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
12895 return true;
12896 }});
12897 if (!filtered.length) {{
12898 tbody.innerHTML = '';
12899 if (empty) empty.style.display = '';
12900 if (count) count.textContent = '';
12901 return;
12902 }}
12903 if (empty) empty.style.display = 'none';
12904 var shown = Math.min(filtered.length, 500);
12905 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
12906 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
12907 var fnCol = f.fn_pct < 0
12908 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
12909 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
12910 return '<tr>' +
12911 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
12912 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
12913 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
12914 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
12915 fnCol +
12916 '</tr>';
12917 }}).join('');
12918 }}
12919
12920 (function() {{
12921 var tabs = document.getElementById('cov-filter-tabs');
12922 if (tabs) {{
12923 tabs.addEventListener('click', function(e) {{
12924 var btn = e.target.closest('.cov-tab');
12925 if (!btn) return;
12926 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
12927 btn.classList.add('active');
12928 covFileTier = btn.getAttribute('data-tier');
12929 buildCovFileTable();
12930 }});
12931 }}
12932 var srch = document.getElementById('cov-file-search');
12933 if (srch) {{
12934 srch.addEventListener('input', function() {{
12935 covFileSearch = this.value;
12936 buildCovFileTable();
12937 }});
12938 }}
12939 }})();
12940
12941 function updateCovGauges(t) {{
12942 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
12943 var el;
12944 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
12945 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
12946 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
12947 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
12948 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
12949 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
12950 }}
12951
12952 function applyScope() {{
12953 var d = getDataset();
12954 var t = d.totals;
12955 var el;
12956 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
12957 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
12958 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
12959 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
12960 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
12961 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
12962 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
12963 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
12964 renderTestCharts(d.lang_tests);
12965 renderAssertionsChart(d.lang_tests);
12966 renderSuitesChart(d.lang_tests);
12967 renderFilesChart(t);
12968 renderCompositionChart(t);
12969 buildLangTable(d.lang_tests);
12970 var covPanel = document.getElementById('cov-panel');
12971 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
12972 if (d.has_coverage) {{
12973 renderCovCharts(d.cov, d.cov_tiers);
12974 updateCovGauges(t);
12975 covFileData = d.file_cov || [];
12976 covFileTier = 'all';
12977 covFileSearch = '';
12978 var tabs = document.getElementById('cov-filter-tabs');
12979 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
12980 var srch = document.getElementById('cov-file-search');
12981 if (srch) srch.value = '';
12982 buildCovFileTable();
12983 }}
12984 loadTrend();
12985 }}
12986
12987 // Populate scope-root-sel from SCOPE_DATA keys
12988 (function() {{
12989 var sel = document.getElementById('scope-root-sel');
12990 if (!sel) return;
12991 Object.keys(SCOPE_DATA).forEach(function(k) {{
12992 if (k === '__all__') return;
12993 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
12994 }});
12995 }})();
12996
12997 document.getElementById('scope-root-sel').addEventListener('change', function() {{
12998 currentRoot = this.value;
12999 currentSub = '';
13000 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
13001 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
13002 var subWrap = document.getElementById('scope-sub-wrap');
13003 var subSel = document.getElementById('scope-sub-sel');
13004 subSel.innerHTML = '<option value="">Entire project</option>';
13005 if (subNames.length) {{
13006 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
13007 subWrap.style.display = 'flex';
13008 }} else {{
13009 subWrap.style.display = 'none';
13010 }}
13011 applyScope();
13012 }});
13013
13014 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
13015 currentSub = this.value;
13016 applyScope();
13017 }});
13018
13019 function buildTrend(data) {{
13020 var trendCanvas = document.getElementById('canvas-trend');
13021 var trendEmpty = document.getElementById('trend-empty');
13022 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
13023 pts = pts.slice().reverse();
13024 currentTrendPts = pts;
13025 if (!pts.length) {{
13026 if (trendCanvas) trendCanvas.style.display = 'none';
13027 if (trendEmpty) trendEmpty.style.display = '';
13028 return;
13029 }}
13030 if (trendCanvas) trendCanvas.style.display = '';
13031 if (trendEmpty) trendEmpty.style.display = 'none';
13032 trendChart = destroyChart(trendChart);
13033 if (!trendCanvas) return;
13034 trendChart = new Chart(trendCanvas, {{
13035 type: 'line',
13036 data: {{
13037 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13038 datasets: [{{
13039 label: 'Test Definitions',
13040 data: pts.map(function(d){{ return d.test_count; }}),
13041 borderColor: '#C45C10',
13042 backgroundColor: 'rgba(196,92,16,0.10)',
13043 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13044 pointRadius: 5, fill: true, tension: 0.3
13045 }}]
13046 }},
13047 options: {{
13048 responsive: true, maintainAspectRatio: false,
13049 layout: {{ padding: {{ top: 22 }} }},
13050 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13051 scales: {{
13052 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
13053 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13054 }}
13055 }},
13056 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13057 }});
13058 ALL_CHARTS.push(trendChart);
13059 }}
13060
13061 // ── Full View expand buttons ──────────────────────────────────────────────
13062 (function() {{
13063 var btn = document.getElementById('tests-expand-btn');
13064 if (!btn) return;
13065 btn.addEventListener('click', function() {{
13066 var D = currentLangTests;
13067 if (!D || !D.length) return;
13068 var top15 = D.slice(0, 15);
13069 var h = Math.max(320, top15.length * 36 + 80);
13070 var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
13071 if (!canvas) return;
13072 new Chart(canvas, {{
13073 type: 'bar',
13074 data: {{
13075 labels: top15.map(function(d){{ return d.lang; }}),
13076 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
13077 }},
13078 options: {{
13079 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13080 layout: {{ padding: {{ right: 72 }} }},
13081 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13082 scales: {{
13083 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13084 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13085 }}
13086 }},
13087 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13088 }});
13089 }});
13090 }})();
13091
13092 (function() {{
13093 var btn = document.getElementById('density-expand-btn');
13094 if (!btn) return;
13095 btn.addEventListener('click', function() {{
13096 var D = currentLangTests;
13097 if (!D || !D.length) return;
13098 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
13099 var h = Math.max(320, topD.length * 36 + 80);
13100 var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
13101 if (!canvas) return;
13102 new Chart(canvas, {{
13103 type: 'bar',
13104 data: {{
13105 labels: topD.map(function(d){{ return d.lang; }}),
13106 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 }}]
13107 }},
13108 options: {{
13109 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13110 layout: {{ padding: {{ right: 72 }} }},
13111 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
13112 scales: {{
13113 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
13114 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13115 }}
13116 }},
13117 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
13118 }});
13119 }});
13120 }})();
13121
13122 (function() {{
13123 var btn = document.getElementById('trend-expand-btn');
13124 if (!btn) return;
13125 btn.addEventListener('click', function() {{
13126 var pts = currentTrendPts;
13127 if (!pts || !pts.length) return;
13128 var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
13129 if (!canvas) return;
13130 new Chart(canvas, {{
13131 type: 'line',
13132 data: {{
13133 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13134 datasets: [{{
13135 label: 'Test Definitions',
13136 data: pts.map(function(d){{ return d.test_count; }}),
13137 borderColor: '#C45C10',
13138 backgroundColor: 'rgba(196,92,16,0.10)',
13139 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13140 pointRadius: 5, fill: true, tension: 0.3
13141 }}]
13142 }},
13143 options: {{
13144 responsive: true, maintainAspectRatio: false,
13145 layout: {{ padding: {{ top: 22 }} }},
13146 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13147 scales: {{
13148 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
13149 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13150 }}
13151 }},
13152 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13153 }});
13154 }});
13155 }})();
13156
13157 (function() {{
13158 var btn = document.getElementById('assertions-expand-btn');
13159 if (!btn) return;
13160 btn.addEventListener('click', function() {{
13161 var D = currentLangTests;
13162 if (!D || !D.length) return;
13163 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
13164 if (!top15.length) return;
13165 var h = Math.max(320, top15.length * 36 + 80);
13166 var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
13167 if (!canvas) return;
13168 new Chart(canvas, {{
13169 type: 'bar',
13170 data: {{
13171 labels: top15.map(function(d){{ return d.lang; }}),
13172 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
13173 }},
13174 options: {{
13175 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13176 layout: {{ padding: {{ right: 72 }} }},
13177 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13178 scales: {{
13179 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13180 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13181 }}
13182 }},
13183 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13184 }});
13185 }});
13186 }})();
13187
13188 (function() {{
13189 var btn = document.getElementById('suites-expand-btn');
13190 if (!btn) return;
13191 btn.addEventListener('click', function() {{
13192 var D = currentLangTests;
13193 if (!D || !D.length) return;
13194 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
13195 if (!top15.length) return;
13196 var h = Math.max(320, top15.length * 36 + 80);
13197 var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
13198 if (!canvas) return;
13199 new Chart(canvas, {{
13200 type: 'bar',
13201 data: {{
13202 labels: top15.map(function(d){{ return d.lang; }}),
13203 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 }}]
13204 }},
13205 options: {{
13206 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13207 layout: {{ padding: {{ right: 72 }} }},
13208 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13209 scales: {{
13210 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13211 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13212 }}
13213 }},
13214 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13215 }});
13216 }});
13217 }})();
13218
13219 function loadTrend() {{
13220 var url = '/api/metrics/history?limit=100';
13221 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
13222 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
13223 buildTrend(data);
13224 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
13225 var btn = document.getElementById('multi-compare-trend-btn');
13226 if (btn) {{
13227 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
13228 if (ids.length >= 2) {{
13229 btn.style.display = '';
13230 btn.onclick = function() {{
13231 // Reverse so oldest first (API returns newest first).
13232 var sorted = ids.slice().reverse();
13233 if (sorted.length === 2) {{
13234 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
13235 }} else {{
13236 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
13237 }}
13238 }};
13239 }} else {{
13240 btn.style.display = 'none';
13241 }}
13242 }}
13243 }}).catch(function(){{
13244 var trendEmpty = document.getElementById('trend-empty');
13245 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
13246 }});
13247 }}
13248
13249 // Re-render charts on theme toggle
13250 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
13251 setTimeout(function() {{
13252 ALL_CHARTS.forEach(function(c) {{
13253 if (c && c.options && c.options.scales) {{
13254 Object.values(c.options.scales).forEach(function(ax) {{
13255 if (ax.grid) ax.grid.color = clr();
13256 if (ax.ticks) ax.ticks.color = txtClr();
13257 }});
13258 c.update();
13259 }}
13260 }});
13261 }}, 80);
13262 }});
13263
13264 applyScope();
13265 }})();
13266 </script>
13267 <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} — 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>
13268</body>
13269</html>"#,
13270 );
13271 Html(html).into_response()
13272}
13273
13274#[derive(Deserialize)]
13281struct EmbedQuery {
13282 run_id: Option<String>,
13283 theme: Option<String>,
13284}
13285
13286async fn embed_handler(
13287 State(state): State<AppState>,
13288 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13289 Query(query): Query<EmbedQuery>,
13290) -> Response {
13291 let entry = {
13292 let reg = state.registry.lock().await;
13293 query.run_id.as_ref().map_or_else(
13294 || reg.entries.first().cloned(),
13295 |id| reg.find_by_run_id(id).cloned(),
13296 )
13297 };
13298
13299 let Some(entry) = entry else {
13300 return Html(
13301 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
13302 .to_string(),
13303 )
13304 .into_response();
13305 };
13306
13307 let dark = query.theme.as_deref() == Some("dark");
13308 let languages: Vec<(String, u64, u64)> = entry
13309 .json_path
13310 .as_ref()
13311 .and_then(|p| read_json(p).ok())
13312 .map(|run| {
13313 run.totals_by_language
13314 .iter()
13315 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
13316 .collect()
13317 })
13318 .unwrap_or_default();
13319
13320 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
13321}
13322
13323fn render_embed_widget(
13324 entry: &RegistryEntry,
13325 languages: &[(String, u64, u64)],
13326 dark: bool,
13327 csp_nonce: &str,
13328) -> String {
13329 let s = &entry.summary;
13330 let total = s.code_lines + s.comment_lines + s.blank_lines;
13331 let code_pct = s
13332 .code_lines
13333 .checked_mul(100)
13334 .and_then(|n| n.checked_div(total))
13335 .unwrap_or(0);
13336
13337 let (bg, fg, surface, muted, border) = if dark {
13338 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
13339 } else {
13340 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
13341 };
13342
13343 let mut lang_rows = String::new();
13344 for (name, files, code) in languages {
13345 write!(
13346 lang_rows,
13347 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
13348 escape_html(name),
13349 format_number(*files),
13350 format_number(*code),
13351 )
13352 .ok();
13353 }
13354
13355 let lang_table = if lang_rows.is_empty() {
13356 String::new()
13357 } else {
13358 format!(
13359 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
13360 )
13361 };
13362
13363 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
13364 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
13365 let project_esc = escape_html(&entry.project_label);
13366 let code_lines = format_number(s.code_lines);
13367 let comment_lines = format_number(s.comment_lines);
13368 let files = format_number(s.files_analyzed);
13369 let code_raw = s.code_lines;
13370 let comment_raw = s.comment_lines;
13371 let blank_raw = s.blank_lines;
13372
13373 format!(
13374 r#"<!doctype html>
13375<html lang="en">
13376<head>
13377 <meta charset="utf-8">
13378 <meta name="viewport" content="width=device-width,initial-scale=1">
13379 <title>OxideSLOC — {project_esc}</title>
13380 <script src="/static/chart.js"></script>
13381 <style nonce="{csp_nonce}">
13382 *{{box-sizing:border-box;margin:0;padding:0}}
13383 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
13384 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
13385 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
13386 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
13387 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
13388 .card .v{{font-size:18px;font-weight:700}}
13389 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
13390 .row{{display:flex;gap:12px;align-items:flex-start}}
13391 .pie{{width:120px;height:120px;flex-shrink:0}}
13392 .lt{{border-collapse:collapse;width:100%;flex:1}}
13393 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
13394 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
13395 .n{{text-align:right}}
13396 .footer{{margin-top:10px;color:{muted};font-size:10px}}
13397 </style>
13398</head>
13399<body>
13400 <h2>{project_esc}</h2>
13401 <div class="sub">{timestamp} · run {run_short}</div>
13402 <div class="cards">
13403 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
13404 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
13405 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
13406 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
13407 </div>
13408 <div class="row">
13409 <canvas class="pie" id="c"></canvas>
13410 {lang_table}
13411 </div>
13412 <div class="footer">oxide-sloc</div>
13413 <script nonce="{csp_nonce}">
13414 new Chart(document.getElementById('c'),{{
13415 type:'doughnut',
13416 data:{{
13417 labels:['Code','Comments','Blank'],
13418 datasets:[{{
13419 data:[{code_raw},{comment_raw},{blank_raw}],
13420 backgroundColor:['#4a78ee','#b35428','#aaa'],
13421 borderWidth:0
13422 }}]
13423 }},
13424 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
13425 }});
13426 </script>
13427</body>
13428</html>"#
13429 )
13430}
13431
13432fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
13437 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
13438 OnceLock::new();
13439 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
13440 let mut guard = map
13441 .lock()
13442 .unwrap_or_else(std::sync::PoisonError::into_inner);
13443 guard
13444 .entry(dir.to_path_buf())
13445 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
13446 .clone()
13447}
13448
13449#[allow(clippy::too_many_lines)]
13450fn persist_run_artifacts(
13451 run: &sloc_core::AnalysisRun,
13452 report_html: &str,
13453 run_dir: &Path,
13454 report_title: &str,
13455 file_stem: &str,
13456 result_context: RunResultContext,
13457) -> Result<(RunArtifacts, PendingPdf)> {
13458 let dir_lock = output_dir_lock(run_dir);
13461 let _dir_guard = dir_lock
13462 .lock()
13463 .unwrap_or_else(std::sync::PoisonError::into_inner);
13464
13465 let html_dir = run_dir.join("html");
13467 let pdf_dir = run_dir.join("pdf");
13468 let excel_dir = run_dir.join("excel");
13469 let json_dir = run_dir.join("json");
13470 let submodules_dir = run_dir.join("submodules");
13471 for dir in &[
13472 run_dir,
13473 &html_dir,
13474 &pdf_dir,
13475 &excel_dir,
13476 &json_dir,
13477 &submodules_dir,
13478 ] {
13479 fs::create_dir_all(dir)
13480 .with_context(|| format!("failed to create directory {}", dir.display()))?;
13481 }
13482
13483 let html_path = {
13485 let path = html_dir.join(format!("report_{file_stem}.html"));
13486 fs::write(&path, report_html)
13487 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
13488 Some(path)
13489 };
13490
13491 let json_path = {
13493 let path = json_dir.join(format!("result_{file_stem}.json"));
13494 let json = serde_json::to_string_pretty(run)
13495 .context("failed to serialize analysis run to JSON")?;
13496 fs::write(&path, json)
13497 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
13498 Some(path)
13499 };
13500
13501 let (pdf_path, pending_pdf) = {
13503 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
13504 match write_pdf_from_run(run, &pdf_dest) {
13505 Ok(()) => {
13506 eprintln!(
13507 "[oxide-sloc][pdf] native PDF written to {}",
13508 pdf_dest.display()
13509 );
13510 (Some(pdf_dest), None)
13511 }
13512 Err(native_err) => {
13513 eprintln!(
13514 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
13515 );
13516 let source_html_path = html_path
13517 .as_ref()
13518 .expect("html_path always Some here")
13519 .clone();
13520 let pending = Some((source_html_path, pdf_dest.clone(), false));
13521 (Some(pdf_dest), pending)
13522 }
13523 }
13524 };
13525
13526 let csv_path = {
13528 let path = excel_dir.join(format!("report_{file_stem}.csv"));
13529 if let Err(e) = sloc_report::write_csv(run, &path) {
13530 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
13531 None
13532 } else {
13533 Some(path)
13534 }
13535 };
13536
13537 let xlsx_path = {
13538 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
13539 if let Err(e) = sloc_report::write_xlsx(run, &path) {
13540 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
13541 None
13542 } else {
13543 Some(path)
13544 }
13545 };
13546
13547 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
13549
13550 if run.effective_configuration.discovery.submodule_breakdown {
13552 let run_id = &run.tool.run_id;
13553 for s in &run.submodule_summaries {
13554 build_submodule_row(s, run, run_id, run_dir);
13555 }
13556 }
13557
13558 generate_offline_index(
13560 run,
13561 run_dir,
13562 file_stem,
13563 html_path.as_deref(),
13564 pdf_path.as_deref(),
13565 json_path.as_deref(),
13566 scan_config_path.as_deref(),
13567 &result_context,
13568 );
13569
13570 Ok((
13571 RunArtifacts {
13572 output_dir: run_dir.to_path_buf(),
13573 html_path,
13574 pdf_path,
13575 json_path,
13576 csv_path,
13577 xlsx_path,
13578 scan_config_path,
13579 report_title: report_title.to_string(),
13580 result_context,
13581 },
13582 pending_pdf,
13583 ))
13584}
13585
13586#[allow(clippy::too_many_arguments)]
13589#[allow(clippy::too_many_lines)]
13590#[allow(clippy::similar_names)]
13591fn generate_offline_index(
13592 run: &sloc_core::AnalysisRun,
13593 run_dir: &Path,
13594 file_stem: &str,
13595 html_path: Option<&Path>,
13596 pdf_path: Option<&Path>,
13597 json_path: Option<&Path>,
13598 scan_config_path: Option<&Path>,
13599 result_context: &RunResultContext,
13600) {
13601 let prev_entry = &result_context.prev_entry;
13602 let prev_scan_count = result_context.prev_scan_count;
13603 let project_path = &result_context.project_path;
13604
13605 let scan_delta = prev_entry.as_ref().and_then(|prev| {
13606 prev.json_path
13607 .as_ref()
13608 .and_then(|p| read_json(p).ok())
13609 .map(|prev_run| compute_delta(&prev_run, run))
13610 });
13611
13612 let files_analyzed = run.per_file_records.len() as u64;
13613 let files_skipped = run.skipped_file_records.len() as u64;
13614 let physical_lines = run
13615 .totals_by_language
13616 .iter()
13617 .map(|r| r.total_physical_lines)
13618 .sum::<u64>();
13619 let code_lines = run
13620 .totals_by_language
13621 .iter()
13622 .map(|r| r.code_lines)
13623 .sum::<u64>();
13624 let comment_lines = run
13625 .totals_by_language
13626 .iter()
13627 .map(|r| r.comment_lines)
13628 .sum::<u64>();
13629 let blank_lines = run
13630 .totals_by_language
13631 .iter()
13632 .map(|r| r.blank_lines)
13633 .sum::<u64>();
13634 let mixed_lines = run
13635 .totals_by_language
13636 .iter()
13637 .map(|r| r.mixed_lines_separate)
13638 .sum::<u64>();
13639 let functions = run
13640 .totals_by_language
13641 .iter()
13642 .map(|r| r.functions)
13643 .sum::<u64>();
13644 let classes = run
13645 .totals_by_language
13646 .iter()
13647 .map(|r| r.classes)
13648 .sum::<u64>();
13649 let variables = run
13650 .totals_by_language
13651 .iter()
13652 .map(|r| r.variables)
13653 .sum::<u64>();
13654 let imports = run
13655 .totals_by_language
13656 .iter()
13657 .map(|r| r.imports)
13658 .sum::<u64>();
13659
13660 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
13661 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
13662 let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
13663 let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
13664 let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
13665 let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
13666 let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
13667 let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
13668
13669 let (delta_fa_str, delta_fa_class) =
13670 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
13671 let (delta_fs_str, delta_fs_class) =
13672 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
13673 let (delta_pl_str, delta_pl_class) =
13674 summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
13675 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
13676 let (delta_cml_str, delta_cml_class) =
13677 summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
13678 let (delta_bl_str, delta_bl_class) =
13679 summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
13680
13681 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
13682 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
13683 let (delta_lines_net_str, delta_lines_net_class) =
13684 match (delta_lines_added, delta_lines_removed) {
13685 (Some(a), Some(r)) => {
13686 let net = a - r;
13687 (fmt_delta(net), delta_class(net).to_string())
13688 }
13689 _ => ("\u{2014}".to_string(), "na".to_string()),
13690 };
13691
13692 let git_commit_url = run
13693 .git_remote_url
13694 .as_deref()
13695 .zip(run.git_commit_long.as_deref())
13696 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
13697 let git_branch_url = run
13698 .git_remote_url
13699 .as_deref()
13700 .zip(run.git_branch.as_deref())
13701 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
13702 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
13703 format!(
13704 "{} / {}",
13705 run.environment.initiator_username, run.environment.initiator_hostname
13706 )
13707 });
13708
13709 let make_rel = |p: Option<&Path>| -> Option<String> {
13711 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
13712 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
13713 };
13714
13715 let run_id = &run.tool.run_id;
13716
13717 let submodule_rows: Vec<SubmoduleRow> = run
13719 .submodule_summaries
13720 .iter()
13721 .map(|s| {
13722 let safe = sanitize_project_label(&s.name);
13723 let key = format!("sub_{safe}");
13724 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
13725 SubmoduleRow {
13726 name: s.name.clone(),
13727 relative_path: s.relative_path.clone(),
13728 files_analyzed: s.files_analyzed,
13729 code_lines: s.code_lines,
13730 comment_lines: s.comment_lines,
13731 blank_lines: s.blank_lines,
13732 total_physical_lines: s.total_physical_lines,
13733 html_url: if sub_path.exists() {
13734 Some(format!("submodules/{key}.html"))
13735 } else {
13736 None
13737 },
13738 }
13739 })
13740 .collect();
13741
13742 let lang_chart_json = {
13743 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
13744 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
13745 let entries: Vec<String> = langs
13746 .into_iter()
13747 .take(12)
13748 .map(|l| {
13749 let name = l.language.display_name()
13750 .replace('\\', "\\\\")
13751 .replace('"', "\\\"");
13752 format!(
13753 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
13754 name, l.code_lines, l.comment_lines, l.blank_lines,
13755 l.total_physical_lines, l.functions, l.classes,
13756 l.variables, l.imports, l.files
13757 )
13758 })
13759 .collect();
13760 format!("[{}]", entries.join(","))
13761 };
13762
13763 let scan_config_rel =
13764 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
13765
13766 let template = ResultTemplate {
13767 version: env!("CARGO_PKG_VERSION"),
13768 report_title: run.effective_configuration.reporting.report_title.clone(),
13769 project_path: project_path.clone(),
13770 output_dir: display_path(run_dir),
13771 run_id: run_id.clone(),
13772 run_id_short: run_id
13773 .split('-')
13774 .next_back()
13775 .unwrap_or(run_id)
13776 .chars()
13777 .take(7)
13778 .collect(),
13779 files_analyzed,
13780 files_skipped,
13781 physical_lines,
13782 code_lines,
13783 comment_lines,
13784 blank_lines,
13785 mixed_lines,
13786 functions,
13787 classes,
13788 variables,
13789 imports,
13790 html_url: make_rel(html_path),
13791 pdf_url: make_rel(pdf_path),
13792 json_url: make_rel(json_path),
13793 html_download_url: make_rel(html_path),
13794 pdf_download_url: make_rel(pdf_path),
13795 json_download_url: make_rel(json_path),
13796 html_path: html_path.map(display_path),
13797 json_path: json_path.map(display_path),
13798 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
13799 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
13800 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
13801 prev_fa_str,
13802 prev_fs_str,
13803 prev_pl_str,
13804 prev_cl_str,
13805 prev_cml_str,
13806 prev_bl_str,
13807 delta_fa_str,
13808 delta_fa_class: delta_fa_class.to_string(),
13809 delta_fs_str,
13810 delta_fs_class: delta_fs_class.to_string(),
13811 delta_pl_str,
13812 delta_pl_class: delta_pl_class.to_string(),
13813 delta_cl_str,
13814 delta_cl_class: delta_cl_class.to_string(),
13815 delta_cml_str,
13816 delta_cml_class: delta_cml_class.to_string(),
13817 delta_bl_str,
13818 delta_bl_class: delta_bl_class.to_string(),
13819 delta_lines_added,
13820 delta_lines_removed,
13821 delta_lines_net_str,
13822 delta_lines_net_class,
13823 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
13824 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
13825 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
13826 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
13827 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
13828 d.file_deltas
13829 .iter()
13830 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
13831 .map(|f| {
13832 #[allow(clippy::cast_sign_loss)]
13833 let n = f.current_code as u64;
13834 n
13835 })
13836 .sum()
13837 }),
13838 git_branch: run.git_branch.clone(),
13839 git_branch_url,
13840 git_commit: run.git_commit_short.clone(),
13841 git_commit_long: run.git_commit_long.clone(),
13842 git_author: run.git_commit_author.clone(),
13843 git_commit_url,
13844 scan_performed_by,
13845 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
13846 os_display: format!(
13847 "{} / {}",
13848 run.environment.operating_system, run.environment.architecture
13849 ),
13850 test_count: run.summary_totals.test_count,
13851 current_scan_number: prev_scan_count + 1,
13852 prev_scan_count,
13853 submodule_rows,
13854 pdf_generating: false,
13855 scan_config_url: scan_config_rel,
13856 lang_chart_json,
13857 scatter_chart_json: String::new(),
13858 semantic_chart_json: String::new(),
13859 submodule_chart_json: String::new(),
13860 has_submodule_data: !run.submodule_summaries.is_empty(),
13861 has_semantic_data: run
13862 .totals_by_language
13863 .iter()
13864 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
13865 csp_nonce: String::new(),
13866 confluence_configured: false,
13867 server_mode: false,
13868 report_header_footer: run
13869 .effective_configuration
13870 .reporting
13871 .report_header_footer
13872 .clone(),
13873 is_offline: true,
13874 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
13875 lsloc: run.summary_totals.lsloc,
13876 uloc: run.uloc,
13877 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
13878 duplicate_group_count: run.duplicate_groups.len(),
13879 has_cocomo: run.cocomo.is_some(),
13880 cocomo_effort_str: run
13881 .cocomo
13882 .as_ref()
13883 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
13884 cocomo_duration_str: run
13885 .cocomo
13886 .as_ref()
13887 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
13888 cocomo_staff_str: run
13889 .cocomo
13890 .as_ref()
13891 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
13892 cocomo_ksloc_str: run
13893 .cocomo
13894 .as_ref()
13895 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
13896 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
13897 || "Organic".to_string(),
13898 |c| {
13899 use sloc_core::CocomoMode;
13900 match c.mode {
13901 CocomoMode::Organic => "Organic",
13902 CocomoMode::SemiDetached => "Semi-detached",
13903 CocomoMode::Embedded => "Embedded",
13904 }
13905 .to_string()
13906 },
13907 ),
13908 cocomo_mode_tooltip: run.cocomo.as_ref().map_or(String::new(), |c| {
13909 use sloc_core::CocomoMode;
13910 match c.mode {
13911 CocomoMode::Organic => {
13912 "Organic: A small team working on a well-understood \
13913 project in a familiar environment with minimal external constraints. \
13914 Suited for internal tools, utilities, and projects with stable requirements. \
13915 Effort = 2.4 \u{00D7} KSLOC^1.05."
13916 }
13917 CocomoMode::SemiDetached => {
13918 "Semi-detached: A mixed team with varying experience \
13919 tackling a project with moderate novelty and some rigid constraints. \
13920 Typical for compilers, transaction systems, and batch processors. \
13921 Effort = 3.0 \u{00D7} KSLOC^1.12."
13922 }
13923 CocomoMode::Embedded => {
13924 "Embedded: Tight hardware, software, or operational \
13925 constraints requiring significant innovation and deep integration work. \
13926 Typical for real-time control systems and safety-critical software. \
13927 Effort = 3.6 \u{00D7} KSLOC^1.20."
13928 }
13929 }
13930 .to_string()
13931 }),
13932 complexity_alert: 0,
13933 };
13934
13935 if let Ok(html) = template.render() {
13936 let index_path = run_dir.join("index.html");
13937 if let Err(e) = fs::write(&index_path, html) {
13938 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
13939 }
13940 }
13941}
13942
13943fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
13946 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
13948 return Some(found);
13949 }
13950 find_scan_config_in_dir_flat(dir)
13952}
13953
13954fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
13955 let exact = dir.join("scan-config.json");
13956 if exact.exists() {
13957 return Some(exact);
13958 }
13959 fs::read_dir(dir).ok().and_then(|entries| {
13960 entries
13961 .filter_map(std::result::Result::ok)
13962 .find(|e| {
13963 let name = e.file_name();
13964 let name = name.to_string_lossy();
13965 name.starts_with("scan-config") && name.ends_with(".json")
13966 })
13967 .map(|e| e.path())
13968 })
13969}
13970
13971#[derive(Deserialize)]
13976struct ExportPdfRequest {
13977 html: String,
13978 #[serde(default)]
13979 filename: Option<String>,
13980}
13981
13982async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
13983 let html_content = body.html;
13984 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
13985 if html_content.is_empty() {
13986 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
13987 }
13988 let tmp_dir = std::env::temp_dir();
13990 let html_path = tmp_dir.join(format!(
13991 "sloc-export-{}.html",
13992 uuid::Uuid::new_v4().simple()
13993 ));
13994 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
13995 if let Err(e) = std::fs::write(&html_path, &html_content) {
13996 return (
13997 StatusCode::INTERNAL_SERVER_ERROR,
13998 format!("Failed to write temp HTML: {e}"),
13999 )
14000 .into_response();
14001 }
14002 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
14003 let _ = std::fs::remove_file(&html_path);
14004 if let Err(e) = pdf_result {
14005 let _ = std::fs::remove_file(&pdf_path);
14006 return (
14007 StatusCode::INTERNAL_SERVER_ERROR,
14008 format!("PDF generation failed: {e}"),
14009 )
14010 .into_response();
14011 }
14012 let pdf_bytes = match std::fs::read(&pdf_path) {
14013 Ok(b) => b,
14014 Err(e) => {
14015 let _ = std::fs::remove_file(&pdf_path);
14016 return (
14017 StatusCode::INTERNAL_SERVER_ERROR,
14018 format!("Failed to read PDF: {e}"),
14019 )
14020 .into_response();
14021 }
14022 };
14023 let _ = std::fs::remove_file(&pdf_path);
14024 let safe_name: String = filename
14025 .chars()
14026 .map(|c| {
14027 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
14028 c
14029 } else {
14030 '_'
14031 }
14032 })
14033 .collect();
14034 let disposition = format!("attachment; filename=\"{safe_name}\"");
14035 (
14036 [
14037 (header::CONTENT_TYPE, "application/pdf".to_string()),
14038 (header::CONTENT_DISPOSITION, disposition),
14039 ],
14040 pdf_bytes,
14041 )
14042 .into_response()
14043}
14044
14045async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
14046 let toml_str = match toml::to_string_pretty(&state.base_config) {
14047 Ok(s) => s,
14048 Err(e) => {
14049 return (
14050 StatusCode::INTERNAL_SERVER_ERROR,
14051 format!("serialization error: {e}"),
14052 )
14053 .into_response();
14054 }
14055 };
14056 (
14057 [
14058 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
14059 (
14060 header::CONTENT_DISPOSITION,
14061 "attachment; filename=\".oxide-sloc.toml\"",
14062 ),
14063 ],
14064 toml_str,
14065 )
14066 .into_response()
14067}
14068
14069#[derive(Serialize)]
14070struct OkResponse {
14071 ok: bool,
14072}
14073
14074#[derive(Serialize)]
14075struct SaveProfileResponse {
14076 ok: bool,
14077 id: String,
14078}
14079
14080#[derive(Serialize)]
14081struct ProfileListResponse {
14082 profiles: Vec<ScanProfile>,
14083}
14084
14085#[derive(Serialize)]
14086struct ImportConfigResponse {
14087 ok: bool,
14088 config: sloc_config::AppConfig,
14089}
14090
14091#[derive(Deserialize)]
14092struct ImportConfigBody {
14093 toml: String,
14094}
14095
14096async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
14097 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
14098 Ok(config) => {
14099 if let Err(e) = config.validate() {
14100 return error::unprocessable_entity(&e.to_string());
14101 }
14102 Json(ImportConfigResponse { ok: true, config }).into_response()
14103 }
14104 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
14105 }
14106}
14107
14108async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
14111 let store = state.scan_profiles.lock().await;
14112 Json(ProfileListResponse {
14113 profiles: store.profiles.clone(),
14114 })
14115}
14116
14117#[derive(Deserialize)]
14118struct SaveScanProfileBody {
14119 name: String,
14120 params: serde_json::Value,
14121}
14122
14123async fn api_save_scan_profile(
14124 State(state): State<AppState>,
14125 Json(body): Json<SaveScanProfileBody>,
14126) -> impl IntoResponse {
14127 if body.name.trim().is_empty() {
14128 return error::bad_request("name must not be empty");
14129 }
14130
14131 let id = uuid::Uuid::new_v4().to_string();
14132 let profile = ScanProfile {
14133 id: id.clone(),
14134 name: body.name.trim().to_string(),
14135 created_at: chrono::Utc::now().to_rfc3339(),
14136 params: body.params,
14137 };
14138
14139 let mut store = state.scan_profiles.lock().await;
14140 store.profiles.push(profile);
14141 if let Err(e) = store.save(&state.scan_profiles_path) {
14142 tracing::warn!("failed to persist scan profiles: {e}");
14143 }
14144 drop(store);
14145
14146 (
14147 StatusCode::CREATED,
14148 Json(SaveProfileResponse { ok: true, id }),
14149 )
14150 .into_response()
14151}
14152
14153async fn api_delete_scan_profile(
14154 State(state): State<AppState>,
14155 AxumPath(id): AxumPath<String>,
14156) -> impl IntoResponse {
14157 let mut store = state.scan_profiles.lock().await;
14158 let before = store.profiles.len();
14159 store.profiles.retain(|p| p.id != id);
14160 if store.profiles.len() == before {
14161 drop(store);
14162 return error::not_found("profile not found");
14163 }
14164 if let Err(e) = store.save(&state.scan_profiles_path) {
14165 tracing::warn!("failed to persist scan profiles: {e}");
14166 }
14167 drop(store);
14168 Json(OkResponse { ok: true }).into_response()
14169}
14170
14171fn resolve_output_root(raw: Option<&str>) -> PathBuf {
14172 let value = raw.unwrap_or("out/web").trim();
14173 let path = if value.is_empty() {
14174 PathBuf::from("out/web")
14175 } else {
14176 PathBuf::from(value)
14177 };
14178
14179 if path.is_absolute() {
14180 path
14181 } else {
14182 workspace_root().join(path)
14183 }
14184}
14185
14186fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
14188 std::env::var("SLOC_GIT_CLONES_DIR")
14189 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
14190}
14191
14192pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
14195 let safe: String = repo_url
14196 .chars()
14197 .map(|c| {
14198 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
14199 c
14200 } else {
14201 '_'
14202 }
14203 })
14204 .take(80)
14205 .collect();
14206 clones_dir.join(safe)
14207}
14208
14209pub(crate) fn scan_path_to_artifacts(
14212 scan_path: &Path,
14213 base_config: &AppConfig,
14214 label: &str,
14215) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
14216 let mut config = base_config.clone();
14217 config.discovery.root_paths = vec![scan_path.to_path_buf()];
14218 label.clone_into(&mut config.reporting.report_title);
14219 let run = analyze(&config, "git", None, None)?;
14220 let html = render_html(&run)?;
14221 let run_id = run.tool.run_id.clone();
14222 let project_label = sanitize_project_label(label);
14223 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
14224 let file_stem = {
14225 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
14226 if commit.is_empty() {
14227 project_label
14228 } else {
14229 format!("{project_label}_{commit}")
14230 }
14231 };
14232 let (artifacts, _pending_pdf) = persist_run_artifacts(
14233 &run,
14234 &html,
14235 &output_dir,
14236 label,
14237 &file_stem,
14238 RunResultContext::default(),
14239 )?;
14240 Ok((run_id, artifacts, run))
14241}
14242
14243async fn restart_poll_schedules(state: &AppState) {
14245 let store = state.schedules.lock().await;
14246 let poll_schedules: Vec<_> = store
14247 .schedules
14248 .iter()
14249 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
14250 .cloned()
14251 .collect();
14252 drop(store);
14253 for schedule in poll_schedules {
14254 let interval = schedule.interval_secs.unwrap_or(300);
14255 let st = state.clone();
14256 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
14257 }
14258}
14259
14260fn split_patterns(raw: Option<&str>) -> Vec<String> {
14261 raw.unwrap_or("")
14262 .lines()
14263 .flat_map(|line| line.split(','))
14264 .map(str::trim)
14265 .filter(|part| !part.is_empty())
14266 .map(ToOwned::to_owned)
14267 .collect()
14268}
14269
14270#[must_use]
14271pub fn build_sub_run(
14272 parent: &AnalysisRun,
14273 sub: &sloc_core::SubmoduleSummary,
14274 parent_path: &str,
14275) -> AnalysisRun {
14276 let sub_files: Vec<_> = parent
14277 .per_file_records
14278 .iter()
14279 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
14280 .cloned()
14281 .collect();
14282 let mut config = parent.effective_configuration.clone();
14283 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
14284
14285 let mut functions = 0u64;
14287 let mut classes = 0u64;
14288 let mut variables = 0u64;
14289 let mut imports = 0u64;
14290 let mut test_count = 0u64;
14291 let mut test_assertion_count = 0u64;
14292 let mut test_suite_count = 0u64;
14293 let mut mixed_lines_separate = 0u64;
14294 let mut coverage_lines_found = 0u64;
14295 let mut coverage_lines_hit = 0u64;
14296 let mut coverage_functions_found = 0u64;
14297 let mut coverage_functions_hit = 0u64;
14298 let mut coverage_branches_found = 0u64;
14299 let mut coverage_branches_hit = 0u64;
14300 for r in &sub_files {
14301 functions += r.raw_line_categories.functions;
14302 classes += r.raw_line_categories.classes;
14303 variables += r.raw_line_categories.variables;
14304 imports += r.raw_line_categories.imports;
14305 test_count += r.raw_line_categories.test_count;
14306 test_assertion_count += r.raw_line_categories.test_assertion_count;
14307 test_suite_count += r.raw_line_categories.test_suite_count;
14308 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
14309 if let Some(cov) = &r.coverage {
14310 coverage_lines_found += u64::from(cov.lines_found);
14311 coverage_lines_hit += u64::from(cov.lines_hit);
14312 coverage_functions_found += u64::from(cov.functions_found);
14313 coverage_functions_hit += u64::from(cov.functions_hit);
14314 coverage_branches_found += u64::from(cov.branches_found);
14315 coverage_branches_hit += u64::from(cov.branches_hit);
14316 }
14317 }
14318
14319 AnalysisRun {
14320 tool: parent.tool.clone(),
14321 environment: parent.environment.clone(),
14322 effective_configuration: config,
14323 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
14324 summary_totals: SummaryTotals {
14325 files_considered: sub.files_analyzed,
14326 files_analyzed: sub.files_analyzed,
14327 files_skipped: 0,
14328 total_physical_lines: sub.total_physical_lines,
14329 code_lines: sub.code_lines,
14330 comment_lines: sub.comment_lines,
14331 blank_lines: sub.blank_lines,
14332 mixed_lines_separate,
14333 functions,
14334 classes,
14335 variables,
14336 imports,
14337 test_count,
14338 test_assertion_count,
14339 test_suite_count,
14340 coverage_lines_found,
14341 coverage_lines_hit,
14342 coverage_functions_found,
14343 coverage_functions_hit,
14344 coverage_branches_found,
14345 coverage_branches_hit,
14346 cyclomatic_complexity: 0,
14347 lsloc: None,
14348 },
14349 totals_by_language: sub.language_summaries.clone(),
14350 per_file_records: sub_files,
14351 skipped_file_records: vec![],
14352 warnings: vec![],
14353 submodule_summaries: vec![],
14354 git_commit_short: sub.git_commit_short.clone(),
14355 git_commit_long: sub.git_commit_long.clone(),
14356 git_branch: sub.git_branch.clone(),
14357 git_commit_author: sub.git_commit_author.clone(),
14358 git_commit_date: sub.git_commit_date.clone(),
14359 git_tags: None,
14360 git_nearest_tag: None,
14361 git_remote_url: sub.git_remote_url.clone(),
14362 style_summary: None,
14363 cocomo: None,
14364 uloc: 0,
14365 dryness_pct: None,
14366 duplicate_groups: vec![],
14367 duplicates_excluded: 0,
14368 }
14369}
14370
14371#[must_use]
14372pub fn sanitize_project_label(raw: &str) -> String {
14373 let candidate = raw
14376 .split(['/', '\\'])
14377 .rfind(|s| !s.is_empty())
14378 .unwrap_or("project");
14379
14380 let mut value = String::with_capacity(candidate.len());
14381 for ch in candidate.chars() {
14382 if ch.is_ascii_alphanumeric() {
14383 value.push(ch.to_ascii_lowercase());
14384 } else {
14385 value.push('-');
14386 }
14387 }
14388
14389 let compact = value.trim_matches('-').to_string();
14390 if compact.is_empty() {
14391 "project".to_string()
14392 } else {
14393 compact
14394 }
14395}
14396
14397fn strip_unc_prefix(path: PathBuf) -> PathBuf {
14400 let s = path.to_string_lossy();
14401 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14402 return PathBuf::from(format!(r"\\{rest}"));
14403 }
14404 if let Some(rest) = s.strip_prefix(r"\\?\") {
14405 return PathBuf::from(rest);
14406 }
14407 path
14408}
14409
14410fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14413 let base = if let Some(rest) = remote.strip_prefix("git@") {
14414 let (host, path) = rest.split_once(':')?;
14415 format!("https://{}/{}", host, path.trim_end_matches(".git"))
14416 } else if remote.starts_with("https://") || remote.starts_with("http://") {
14417 remote
14418 .trim_end_matches('/')
14419 .trim_end_matches(".git")
14420 .to_owned()
14421 } else {
14422 return None;
14423 };
14424 let base = base.trim_end_matches('/');
14425 if base.contains("gitlab.com") || base.contains("gitlab.") {
14427 Some(format!("{base}/-/commit/{sha}"))
14428 } else if base.contains("bitbucket.org") {
14429 Some(format!("{base}/commits/{sha}"))
14430 } else {
14431 Some(format!("{base}/commit/{sha}"))
14432 }
14433}
14434
14435fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
14438 let base = if let Some(rest) = remote.strip_prefix("git@") {
14439 let (host, path) = rest.split_once(':')?;
14440 format!("https://{}/{}", host, path.trim_end_matches(".git"))
14441 } else if remote.starts_with("https://") || remote.starts_with("http://") {
14442 remote
14443 .trim_end_matches('/')
14444 .trim_end_matches(".git")
14445 .to_owned()
14446 } else {
14447 return None;
14448 };
14449 let base = base.trim_end_matches('/');
14450 if base.contains("gitlab.com") || base.contains("gitlab.") {
14451 Some(format!("{base}/-/tree/{branch}"))
14452 } else {
14453 Some(format!("{base}/tree/{branch}"))
14454 }
14455}
14456
14457fn display_path(path: &Path) -> String {
14458 let s = path.to_string_lossy();
14459 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14464 return format!(r"\\{rest}");
14465 }
14466 if let Some(rest) = s.strip_prefix(r"\\?\") {
14467 return rest.to_owned();
14468 }
14469 s.into_owned()
14470}
14471
14472fn sanitize_path_str(s: &str) -> String {
14473 if let Some(rest) = s.strip_prefix("//?/UNC/") {
14477 return format!("//{rest}");
14478 }
14479 if let Some(rest) = s.strip_prefix("//?/") {
14480 return rest.to_owned();
14481 }
14482 display_path(Path::new(s))
14483}
14484
14485fn workspace_root() -> PathBuf {
14486 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
14488 let p = PathBuf::from(root);
14489 if p.is_dir() {
14490 return p;
14491 }
14492 }
14493
14494 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
14497}
14498
14499fn make_git_label(repo: &str, ref_name: &str) -> String {
14501 if repo.is_empty() || ref_name.is_empty() {
14502 return String::new();
14503 }
14504 let base = repo
14505 .trim_end_matches('/')
14506 .trim_end_matches(".git")
14507 .rsplit('/')
14508 .next()
14509 .unwrap_or("repo");
14510 let ref_safe: String = ref_name
14511 .chars()
14512 .map(|c| {
14513 if c.is_alphanumeric() || c == '-' || c == '.' {
14514 c
14515 } else {
14516 '_'
14517 }
14518 })
14519 .collect();
14520 format!("{base}_at_{ref_safe}_sloc")
14521}
14522
14523fn desktop_dir() -> PathBuf {
14525 if let Ok(profile) = std::env::var("USERPROFILE") {
14526 let p = PathBuf::from(profile).join("Desktop");
14527 if p.exists() {
14528 return p;
14529 }
14530 }
14531 if let Ok(home) = std::env::var("HOME") {
14532 let p = PathBuf::from(home).join("Desktop");
14533 if p.exists() {
14534 return p;
14535 }
14536 }
14537 workspace_root().join("out").join("web")
14538}
14539
14540fn resolve_input_path(raw: &str) -> PathBuf {
14541 let trimmed = raw.trim();
14542 if trimmed.is_empty() {
14543 return workspace_root().join("samples").join("basic");
14544 }
14545
14546 let candidate = PathBuf::from(trimmed);
14547 let resolved = if candidate.is_absolute() {
14548 candidate
14549 } else {
14550 let rooted = workspace_root().join(&candidate);
14551 if rooted.exists() {
14552 rooted
14553 } else {
14554 workspace_root().join(candidate)
14555 }
14556 };
14557
14558 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
14561 PathBuf::from(display_path(&canonical))
14562}
14563
14564fn dir_size_bytes(path: &Path) -> u64 {
14565 let mut total = 0u64;
14566 if let Ok(rd) = fs::read_dir(path) {
14567 for entry in rd.filter_map(Result::ok) {
14568 let p = entry.path();
14569 if p.is_file() {
14570 if let Ok(meta) = p.metadata() {
14571 total += meta.len();
14572 }
14573 } else if p.is_dir() {
14574 total += dir_size_bytes(&p);
14575 }
14576 }
14577 }
14578 total
14579}
14580
14581#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
14583 if bytes >= 1_073_741_824 {
14584 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
14585 } else if bytes >= 1_048_576 {
14586 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
14587 } else if bytes >= 1_024 {
14588 format!("{:.0} KB", bytes as f64 / 1_024.0)
14589 } else {
14590 format!("{bytes} B")
14591 }
14592}
14593
14594fn render_submodule_chips(
14595 root: &Path,
14596 submodules: &[(String, std::path::PathBuf)],
14597 out: &mut String,
14598) {
14599 use std::fmt::Write as _;
14600 let count = submodules.len();
14601 out.push_str(r#"<div class="submodule-preview-strip">"#);
14602 write!(
14603 out,
14604 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>"#,
14605 if count == 1 { "" } else { "s" }
14606 )
14607 .ok();
14608 out.push_str(r#"<div class="submodule-preview-chips">"#);
14609 for (sub_name, sub_rel_path) in submodules {
14610 let sub_abs = root.join(sub_rel_path);
14611 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
14612 let mut sub_stats = PreviewStats::default();
14613 let mut sub_rows: Vec<PreviewRow> = Vec::new();
14614 let mut sub_langs: Vec<&'static str> = Vec::new();
14615 let mut sub_budget = PreviewBudget {
14616 shown: 0,
14617 max_entries: 2000,
14618 max_depth: 9,
14619 };
14620 let mut sub_next_id = 1usize;
14621 let _ = collect_preview_rows(
14622 &sub_abs,
14623 &sub_abs,
14624 0,
14625 None,
14626 &mut sub_next_id,
14627 &mut sub_budget,
14628 &mut sub_stats,
14629 &mut sub_rows,
14630 &mut sub_langs,
14631 &[],
14632 &[],
14633 );
14634 let stats_json = format!(
14635 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
14636 sub_stats.directories,
14637 sub_stats.files,
14638 sub_stats.supported,
14639 sub_stats.skipped,
14640 sub_stats.unsupported
14641 );
14642 write!(
14643 out,
14644 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>"#,
14645 escape_html(sub_name),
14646 escape_html(&sub_rel_path.to_string_lossy()),
14647 escape_html(&sub_size),
14648 escape_html(&stats_json),
14649 escape_html(sub_name),
14650 escape_html(&sub_size),
14651 )
14652 .ok();
14653 }
14654 out.push_str(
14655 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
14656 );
14657 out.push_str(r"</div>");
14658}
14659
14660fn render_language_pills_row(languages: &[&str], out: &mut String) {
14661 use std::fmt::Write as _;
14662 if languages.is_empty() {
14663 out.push_str(
14664 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
14665 );
14666 return;
14667 }
14668 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
14669 for language in languages {
14670 if let Some(icon) = language_icon_file(language) {
14671 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();
14672 } else if let Some(svg) = language_inline_svg(language) {
14673 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();
14674 } else {
14675 write!(
14676 out,
14677 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
14678 escape_html(&language.to_ascii_lowercase()),
14679 escape_html(language)
14680 )
14681 .ok();
14682 }
14683 }
14684}
14685
14686#[allow(clippy::too_many_lines)]
14687fn build_preview_html(
14688 root: &Path,
14689 include_patterns: &[String],
14690 exclude_patterns: &[String],
14691) -> Result<String> {
14692 if !root.exists() {
14693 return Ok(format!(
14694 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
14695 escape_html(&display_path(root))
14696 ));
14697 }
14698
14699 let _selected = display_path(root);
14700 let mut stats = PreviewStats::default();
14701 let mut rows = Vec::new();
14702 let mut languages = Vec::new();
14703 let mut budget = PreviewBudget {
14704 shown: 0,
14705 max_entries: 600,
14706 max_depth: 9,
14707 };
14708 let mut next_row_id = 1usize;
14709
14710 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
14711 || root.to_string_lossy().into_owned(),
14712 std::string::ToString::to_string,
14713 );
14714 let root_modified = root
14715 .metadata()
14716 .ok()
14717 .and_then(|meta| meta.modified().ok())
14718 .map_or_else(|| "-".to_string(), format_system_time);
14719
14720 rows.push(PreviewRow {
14721 row_id: 0,
14722 parent_row_id: None,
14723 depth: 0,
14724 name: format!("{root_name}/"),
14725 kind: PreviewKind::Dir,
14726 is_dir: true,
14727 language: None,
14728 modified: root_modified,
14729 type_label: "Directory".to_string(),
14730 });
14731 collect_preview_rows(
14732 root,
14733 root,
14734 0,
14735 Some(0),
14736 &mut next_row_id,
14737 &mut budget,
14738 &mut stats,
14739 &mut rows,
14740 &mut languages,
14741 include_patterns,
14742 exclude_patterns,
14743 )?;
14744
14745 let root_size = format_dir_size(dir_size_bytes(root));
14746
14747 let mut out = String::new();
14748 write!(
14749 out,
14750 r#"<div class="explorer-wrap" data-project-size="{}">"#,
14751 escape_html(&root_size)
14752 )
14753 .ok();
14754 out.push_str(r#"<div class="explorer-toolbar compact">"#);
14755 out.push_str(r#"<div class="explorer-title-group">"#);
14756 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
14757 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
14758 out.push_str(r"</div></div>");
14759
14760 out.push_str(r#"<div class="scope-stats">"#);
14761 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();
14762 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();
14763 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();
14764 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();
14765 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();
14766 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>"#);
14767 out.push_str(r"</div>");
14768
14769 let submodules = sloc_core::detect_submodules(root);
14770 if !submodules.is_empty() {
14771 render_submodule_chips(root, &submodules, &mut out);
14772 }
14773
14774 out.push_str(r#"<div class="scope-info-row">"#);
14775 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
14776 render_language_pills_row(&languages, &mut out);
14777 out.push_str(r"</div></div>");
14778 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>"#);
14779 out.push_str(r"</div>");
14780
14781 out.push_str(r#"<div class="file-explorer-shell">"#);
14782 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>"#);
14783 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>"#);
14784 out.push_str(r#"<div class="file-explorer-tree">"#);
14785 for row in rows {
14786 let status_label = row.kind.label();
14787 let lang_attr = row.language.unwrap_or("");
14788 let toggle_html = if row.is_dir {
14789 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
14790 .to_string()
14791 } else {
14792 r#"<span class="tree-bullet">•</span>"#.to_string()
14793 };
14794 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();
14795 }
14796 if budget.shown >= budget.max_entries {
14797 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>"#);
14798 }
14799 out.push_str(r"</div></div></div>");
14800
14801 Ok(out)
14802}
14803
14804#[derive(Default)]
14805struct PreviewStats {
14806 directories: usize,
14807 files: usize,
14808 supported: usize,
14809 skipped: usize,
14810 unsupported: usize,
14811}
14812
14813struct PreviewRow {
14814 row_id: usize,
14815 parent_row_id: Option<usize>,
14816 depth: usize,
14817 name: String,
14818 kind: PreviewKind,
14819 is_dir: bool,
14820 language: Option<&'static str>,
14821 modified: String,
14822 type_label: String,
14823}
14824
14825#[derive(Copy, Clone)]
14826enum PreviewKind {
14827 Dir,
14828 Supported,
14829 Skipped,
14830 Unsupported,
14831}
14832
14833impl PreviewKind {
14834 const fn filter_key(self) -> &'static str {
14835 match self {
14836 Self::Dir => "dir",
14837 Self::Supported => "supported",
14838 Self::Skipped => "skipped",
14839 Self::Unsupported => "unsupported",
14840 }
14841 }
14842
14843 const fn label(self) -> &'static str {
14844 match self {
14845 Self::Dir => "dir",
14846 Self::Supported => "supported",
14847 Self::Skipped => "skipped by policy",
14848 Self::Unsupported => "unsupported",
14849 }
14850 }
14851
14852 const fn badge_class(self) -> &'static str {
14853 match self {
14854 Self::Dir => "badge badge-dir",
14855 Self::Supported => "badge badge-scan",
14856 Self::Skipped => "badge badge-skip",
14857 Self::Unsupported => "badge badge-unsupported",
14858 }
14859 }
14860
14861 const fn node_class(self) -> &'static str {
14862 match self {
14863 Self::Dir => "tree-node-dir",
14864 Self::Supported => "tree-node-supported",
14865 Self::Skipped => "tree-node-skipped",
14866 Self::Unsupported => "tree-node-unsupported",
14867 }
14868 }
14869}
14870
14871struct PreviewBudget {
14872 shown: usize,
14873 max_entries: usize,
14874 max_depth: usize,
14875}
14876
14877#[allow(clippy::too_many_arguments)]
14880fn handle_preview_dir_entry(
14881 root: &Path,
14882 path: &Path,
14883 name: &str,
14884 modified: String,
14885 depth: usize,
14886 parent_row_id: Option<usize>,
14887 row_id: usize,
14888 next_row_id: &mut usize,
14889 budget: &mut PreviewBudget,
14890 stats: &mut PreviewStats,
14891 rows: &mut Vec<PreviewRow>,
14892 languages: &mut Vec<&'static str>,
14893 include_patterns: &[String],
14894 exclude_patterns: &[String],
14895) -> Result<()> {
14896 let relative = preview_relative_path(root, path);
14897 if should_skip_preview_directory(&relative, exclude_patterns) {
14898 return Ok(());
14899 }
14900 stats.directories += 1;
14901 rows.push(PreviewRow {
14902 row_id,
14903 parent_row_id,
14904 depth: depth + 1,
14905 name: format!("{name}/"),
14906 kind: PreviewKind::Dir,
14907 is_dir: true,
14908 language: None,
14909 modified,
14910 type_label: "Directory".to_string(),
14911 });
14912 budget.shown += 1;
14913 if !matches!(name, ".git" | "node_modules" | "target") {
14914 collect_preview_rows(
14915 root,
14916 path,
14917 depth + 1,
14918 Some(row_id),
14919 next_row_id,
14920 budget,
14921 stats,
14922 rows,
14923 languages,
14924 include_patterns,
14925 exclude_patterns,
14926 )?;
14927 }
14928 Ok(())
14929}
14930
14931#[allow(clippy::too_many_arguments)]
14933fn handle_preview_file_entry(
14934 root: &Path,
14935 path: &Path,
14936 name: &str,
14937 modified: String,
14938 depth: usize,
14939 parent_row_id: Option<usize>,
14940 row_id: usize,
14941 budget: &mut PreviewBudget,
14942 stats: &mut PreviewStats,
14943 rows: &mut Vec<PreviewRow>,
14944 languages: &mut Vec<&'static str>,
14945 include_patterns: &[String],
14946 exclude_patterns: &[String],
14947) {
14948 let relative = preview_relative_path(root, path);
14949 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
14950 return;
14951 }
14952 stats.files += 1;
14953 let kind = classify_preview_file(name);
14954 match kind {
14955 PreviewKind::Supported => stats.supported += 1,
14956 PreviewKind::Skipped => stats.skipped += 1,
14957 PreviewKind::Unsupported => stats.unsupported += 1,
14958 PreviewKind::Dir => {}
14959 }
14960 let language = detect_language_name(name);
14961 if let Some(lang) = language {
14962 if !languages.contains(&lang) {
14963 languages.push(lang);
14964 }
14965 }
14966 rows.push(PreviewRow {
14967 row_id,
14968 parent_row_id,
14969 depth: depth + 1,
14970 name: name.to_owned(),
14971 kind,
14972 is_dir: false,
14973 language,
14974 modified,
14975 type_label: preview_type_label(name, language, kind),
14976 });
14977 budget.shown += 1;
14978}
14979
14980#[allow(clippy::too_many_arguments)]
14981#[allow(clippy::too_many_lines)]
14982fn collect_preview_rows(
14983 root: &Path,
14984 dir: &Path,
14985 depth: usize,
14986 parent_row_id: Option<usize>,
14987 next_row_id: &mut usize,
14988 budget: &mut PreviewBudget,
14989 stats: &mut PreviewStats,
14990 rows: &mut Vec<PreviewRow>,
14991 languages: &mut Vec<&'static str>,
14992 include_patterns: &[String],
14993 exclude_patterns: &[String],
14994) -> Result<()> {
14995 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
14996 return Ok(());
14997 }
14998
14999 let mut entries = fs::read_dir(dir)
15000 .with_context(|| format!("failed to read directory {}", dir.display()))?
15001 .filter_map(std::result::Result::ok)
15002 .collect::<Vec<_>>();
15003 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
15004
15005 for entry in entries {
15006 if budget.shown >= budget.max_entries {
15007 break;
15008 }
15009
15010 let path = entry.path();
15011 let name = entry.file_name().to_string_lossy().into_owned();
15012 let Ok(metadata) = entry.metadata() else {
15013 continue;
15014 };
15015 let row_id = *next_row_id;
15016 *next_row_id += 1;
15017 let modified = metadata
15018 .modified()
15019 .ok()
15020 .map_or_else(|| "-".to_string(), format_system_time);
15021
15022 if metadata.is_dir() {
15023 handle_preview_dir_entry(
15024 root,
15025 &path,
15026 &name,
15027 modified,
15028 depth,
15029 parent_row_id,
15030 row_id,
15031 next_row_id,
15032 budget,
15033 stats,
15034 rows,
15035 languages,
15036 include_patterns,
15037 exclude_patterns,
15038 )?;
15039 continue;
15040 }
15041
15042 if metadata.is_file() {
15043 handle_preview_file_entry(
15044 root,
15045 &path,
15046 &name,
15047 modified,
15048 depth,
15049 parent_row_id,
15050 row_id,
15051 budget,
15052 stats,
15053 rows,
15054 languages,
15055 include_patterns,
15056 exclude_patterns,
15057 );
15058 }
15059 }
15060
15061 Ok(())
15062}
15063
15064fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
15065 if let Some(language) = language {
15066 return format!("{language} source");
15067 }
15068 let lower = name.to_ascii_lowercase();
15069 let ext = Path::new(&lower)
15070 .extension()
15071 .and_then(|e| e.to_str())
15072 .unwrap_or("");
15073 match kind {
15074 PreviewKind::Skipped => {
15075 if lower.ends_with(".min.js") {
15076 "Minified asset".to_string()
15077 } else if [
15078 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
15079 ]
15080 .contains(&ext)
15081 {
15082 "Binary or archive".to_string()
15083 } else {
15084 "Skipped file".to_string()
15085 }
15086 }
15087 PreviewKind::Unsupported => {
15088 if ext.is_empty() {
15089 "Unsupported file".to_string()
15090 } else {
15091 format!("{} file", ext.to_ascii_uppercase())
15092 }
15093 }
15094 PreviewKind::Supported => "Supported source".to_string(),
15095 PreviewKind::Dir => "Directory".to_string(),
15096 }
15097}
15098
15099fn format_system_time(time: SystemTime) -> String {
15100 #[allow(clippy::cast_possible_wrap)]
15101 let secs = match time.duration_since(UNIX_EPOCH) {
15102 Ok(duration) => duration.as_secs() as i64,
15103 Err(_) => return "-".to_string(),
15104 };
15105 let days = secs.div_euclid(86_400);
15106 let secs_of_day = secs.rem_euclid(86_400);
15107 let (year, month, day) = civil_from_days(days);
15108 let hour = secs_of_day / 3_600;
15109 let minute = (secs_of_day % 3_600) / 60;
15110 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
15111}
15112
15113#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
15114fn civil_from_days(days: i64) -> (i32, u32, u32) {
15115 let z = days + 719_468;
15116 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
15117 let doe = z - era * 146_097;
15118 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
15119 let y = yoe + era * 400;
15120 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
15121 let mp = (5 * doy + 2) / 153;
15122 let d = doy - (153 * mp + 2) / 5 + 1;
15123 let m = mp + if mp < 10 { 3 } else { -9 };
15124 let year = y + i64::from(m <= 2);
15125 (year as i32, m as u32, d as u32)
15126}
15127
15128#[allow(clippy::case_sensitive_file_extension_comparisons)]
15131fn detect_language_name(name: &str) -> Option<&'static str> {
15132 let lower = name.to_ascii_lowercase();
15133 if lower.ends_with(".c") || lower.ends_with(".h") {
15134 Some("C")
15135 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
15136 .iter()
15137 .any(|s| lower.ends_with(s))
15138 {
15139 Some("C++")
15140 } else if lower.ends_with(".cs") {
15141 Some("C#")
15142 } else if lower.ends_with(".py") {
15143 Some("Python")
15144 } else if lower.ends_with(".sh") {
15145 Some("Shell")
15146 } else if [".ps1", ".psm1", ".psd1"]
15147 .iter()
15148 .any(|s| lower.ends_with(s))
15149 {
15150 Some("PowerShell")
15151 } else {
15152 None
15153 }
15154}
15155
15156fn language_icon_file(language: &str) -> Option<&'static str> {
15157 match language {
15158 "C" => Some("c.png"),
15159 "C++" => Some("cpp.png"),
15160 "C#" => Some("c-sharp.png"),
15161 "Python" => Some("python.png"),
15162 "Shell" => Some("shell.png"),
15163 "PowerShell" => Some("powershell.png"),
15164 "JavaScript" => Some("java-script.png"),
15165 "HTML" => Some("html-5.png"),
15166 "Java" => Some("java.png"),
15167 "Visual Basic" => Some("visual-basic.png"),
15168 "Assembly" => Some("asm.png"),
15169 "Go" => Some("go.png"),
15170 "R" => Some("r.png"),
15171 "XML" => Some("xml.png"),
15172 "Groovy" => Some("groovy.png"),
15173 "Dockerfile" => Some("docker.png"),
15174 "Makefile" => Some("makefile.svg"),
15175 "Perl" => Some("perl.svg"),
15176 _ => None,
15177 }
15178}
15179
15180fn language_inline_svg(language: &str) -> Option<&'static str> {
15185 match language {
15186 "Rust" => Some(
15187 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>"##,
15188 ),
15189 "TypeScript" => Some(
15190 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>"##,
15191 ),
15192 _ => None,
15193 }
15194}
15195
15196#[allow(clippy::case_sensitive_file_extension_comparisons)]
15199fn classify_preview_file(name: &str) -> PreviewKind {
15200 let lower = name.to_ascii_lowercase();
15201
15202 let scannable = [
15203 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
15204 ".psm1", ".psd1",
15205 ]
15206 .iter()
15207 .any(|suffix| lower.ends_with(suffix));
15208
15209 if scannable {
15210 PreviewKind::Supported
15211 } else if lower.ends_with(".min.js")
15212 || lower.ends_with(".lock")
15213 || lower.ends_with(".png")
15214 || lower.ends_with(".jpg")
15215 || lower.ends_with(".jpeg")
15216 || lower.ends_with(".gif")
15217 || lower.ends_with(".zip")
15218 || lower.ends_with(".pdf")
15219 || lower.ends_with(".pyc")
15220 || lower.ends_with(".xz")
15221 || lower.ends_with(".tar")
15222 || lower.ends_with(".gz")
15223 {
15224 PreviewKind::Skipped
15225 } else {
15226 PreviewKind::Unsupported
15227 }
15228}
15229
15230fn preview_relative_path(root: &Path, path: &Path) -> String {
15231 path.strip_prefix(root)
15232 .ok()
15233 .unwrap_or(path)
15234 .to_string_lossy()
15235 .replace('\\', "/")
15236 .trim_matches('/')
15237 .to_string()
15238}
15239
15240fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
15241 if relative.is_empty() {
15242 return false;
15243 }
15244
15245 exclude_patterns.iter().any(|pattern| {
15246 wildcard_match(pattern, relative)
15247 || wildcard_match(pattern, &format!("{relative}/"))
15248 || wildcard_match(pattern, &format!("{relative}/placeholder"))
15249 })
15250}
15251
15252fn should_include_preview_file(
15253 relative: &str,
15254 include_patterns: &[String],
15255 exclude_patterns: &[String],
15256) -> bool {
15257 if relative.is_empty() {
15258 return true;
15259 }
15260
15261 let included = include_patterns.is_empty()
15262 || include_patterns
15263 .iter()
15264 .any(|pattern| wildcard_match(pattern, relative));
15265 let excluded = exclude_patterns
15266 .iter()
15267 .any(|pattern| wildcard_match(pattern, relative));
15268
15269 included && !excluded
15270}
15271
15272fn wildcard_match(pattern: &str, candidate: &str) -> bool {
15273 let pattern = pattern.trim().replace('\\', "/");
15274 let candidate = candidate.trim().replace('\\', "/");
15275 let p = pattern.as_bytes();
15276 let c = candidate.as_bytes();
15277 let mut pi = 0usize;
15278 let mut ci = 0usize;
15279 let mut star: Option<usize> = None;
15280 let mut star_match = 0usize;
15281
15282 while ci < c.len() {
15283 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
15284 pi += 1;
15285 ci += 1;
15286 } else if pi < p.len() && p[pi] == b'*' {
15287 while pi < p.len() && p[pi] == b'*' {
15288 pi += 1;
15289 }
15290 star = Some(pi);
15291 star_match = ci;
15292 } else if let Some(star_pi) = star {
15293 star_match += 1;
15294 ci = star_match;
15295 pi = star_pi;
15296 } else {
15297 return false;
15298 }
15299 }
15300
15301 while pi < p.len() && p[pi] == b'*' {
15302 pi += 1;
15303 }
15304
15305 pi == p.len()
15306}
15307
15308fn escape_html(value: &str) -> String {
15309 value
15310 .replace('&', "&")
15311 .replace('<', "<")
15312 .replace('>', ">")
15313 .replace('"', """)
15314 .replace('\'', "'")
15315}
15316
15317#[derive(Clone)]
15318struct SubmoduleRow {
15319 name: String,
15320 relative_path: String,
15321 files_analyzed: u64,
15322 code_lines: u64,
15323 comment_lines: u64,
15324 blank_lines: u64,
15325 total_physical_lines: u64,
15326 html_url: Option<String>,
15327}
15328
15329#[derive(Template)]
15330#[template(
15331 source = r##"
15332<!doctype html>
15333<html lang="en">
15334<head>
15335 <meta charset="utf-8">
15336 <title>OxideSLOC | tmp-sloc</title>
15337 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15338 <style nonce="{{ csp_nonce }}">
15339 :root {
15340 --bg: #efe9e2;
15341 --surface: #fcfaf7;
15342 --surface-2: #f7f0e8;
15343 --surface-3: #efe3d5;
15344 --line: #dfcfbf;
15345 --line-strong: #cfb29c;
15346 --text: #2f241c;
15347 --muted: #6f6257;
15348 --muted-2: #917f71;
15349 --nav: #b85d33;
15350 --nav-2: #7a371b;
15351 --accent: #2563eb;
15352 --accent-2: #1d4ed8;
15353 --oxide: #b85d33;
15354 --oxide-2: #8f4220;
15355 --success-bg: #eaf9ee;
15356 --success-text: #1c8746;
15357 --warn-bg: #fff2d8;
15358 --warn-text: #926000;
15359 --danger-bg: #fdeaea;
15360 --danger-text: #b33b3b;
15361 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
15362 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
15363 --radius: 14px;
15364 }
15365
15366 body.dark-theme {
15367 --bg: #1b1511;
15368 --surface: #261c17;
15369 --surface-2: #2d221d;
15370 --surface-3: #372922;
15371 --line: #524238;
15372 --line-strong: #6c5649;
15373 --text: #f5ece6;
15374 --muted: #c7b7aa;
15375 --muted-2: #aa9485;
15376 --nav: #b85d33;
15377 --nav-2: #7a371b;
15378 --accent: #6f9bff;
15379 --accent-2: #4a78ee;
15380 --oxide: #d37a4c;
15381 --oxide-2: #b35428;
15382 --success-bg: #163927;
15383 --success-text: #8fe2a8;
15384 --warn-bg: #3c2d11;
15385 --warn-text: #f3cb75;
15386 --danger-bg: #3d1f1f;
15387 --danger-text: #ff9f9f;
15388 --shadow: 0 14px 28px rgba(0,0,0,0.28);
15389 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
15390 }
15391
15392 * { box-sizing: border-box; }
15393 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); }
15394 html { overflow-y: scroll; }
15395 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15396 .top-nav, .page, .loading { position: relative; z-index: 2; }
15397 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15398 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15399 .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); }
15400 .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; }
15401 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15402 .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)); }
15403 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15404 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15405 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15406 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15407 .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; }
15408 .nav-project-pill.visible { display:inline-flex; }
15409 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15410 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15411 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15412 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15413 @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; } }
15414 .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; }
15415 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
15416 .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; }
15417 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15418 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15419 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15420 .theme-toggle .icon-sun { display:none; }
15421 body.dark-theme .theme-toggle .icon-sun { display:block; }
15422 body.dark-theme .theme-toggle .icon-moon { display:none; }
15423 .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;}
15424 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15425 .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);}
15426 .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;}
15427 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15428 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15429 .settings-modal-body{padding:14px 16px 16px;}
15430 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15431 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15432 .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;}
15433 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15434 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15435 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15436 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15437 .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;}
15438 .tz-select:focus{border-color:var(--oxide);}
15439 .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; }
15440 .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;}
15441 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
15442 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
15443 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
15444 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
15445 .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; }
15446 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
15447 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
15448 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
15449 .wb-stats-header { padding: 10px 24px 0; }
15450 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
15451 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
15452 .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; }
15453 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15454 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15455 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15456 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
15457 .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; }
15458 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
15459 .ws-stat-analyzers { position: relative; }
15460 .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; }
15461 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
15462 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
15463 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
15464 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
15465 .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; }
15466 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
15467 .ws-divider { display: none; }
15468 .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%; }
15469 .ws-path-link:hover { color:var(--oxide); }
15470 body.dark-theme .ws-path-link { color:var(--oxide); }
15471 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
15472 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15473 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
15474 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15475 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
15476 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
15477 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
15478 .ws-mini-box-lg { flex:2 1 0; }
15479 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
15480 .ws-mini-box-br { flex:1.5 1 0; }
15481 .scope-legend-row { display:inline-flex; flex-direction:row; align-items:center; flex-wrap:wrap; gap:6px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:13px; flex-shrink:0; border-left:3px solid var(--line-strong); }
15482 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
15483 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
15484 #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; }
15485 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
15486 .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; }
15487 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
15488 .git-source-banner strong { font-weight:800; color:var(--text); }
15489 .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; }
15490 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
15491 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
15492 .git-source-banner a:hover { text-decoration:underline; }
15493 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
15494 .path-scope-sep { background:var(--line); margin:4px 14px; }
15495 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
15496 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
15497 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
15498 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
15499 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
15500 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
15501 .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; }
15502 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15503 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15504 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15505 .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; }
15506 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
15507 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
15508 [data-wb-tip] { cursor:help; }
15509 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
15510 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
15511 .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; }
15512 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
15513 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
15514 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
15515 .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; }
15516 .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); }
15517 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
15518 .side-info-card { padding: 18px; }
15519 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
15520 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
15521 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
15522 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
15523 .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); }
15524 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
15525 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15526 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
15527 .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; }
15528 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
15529 .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; }
15530 .side-stack::-webkit-scrollbar { display: none; }
15531 .step-nav { padding: 20px 16px; }
15532 .step-nav h3 { margin: 6px 4px 20px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
15533 .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; }
15534 .step-button:hover { background: var(--surface-2); }
15535 .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); }
15536 .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; }
15537 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15538 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
15539 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
15540 .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); }
15541 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
15542 .step-nav-sum-row:last-child { border-bottom:none; }
15543 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
15544 .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; }
15545 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
15546 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
15547 .quick-scan-section { padding: 10px 4px 14px; }
15548 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
15549 .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; }
15550 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
15551 .quick-scan-btn:active { transform:translateY(0); }
15552 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
15553 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
15554 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
15555 @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);} }
15556 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
15557 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
15558 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
15559 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
15560 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
15561 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
15562 .step-button.done .step-check { opacity:1; }
15563 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
15564 .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; }
15565 .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; }
15566 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
15567 .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; }
15568 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
15569 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
15570 .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; }
15571 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
15572 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15573 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
15574 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
15575 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
15576 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
15577 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
15578 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
15579 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
15580 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
15581 .card-body { padding: 22px; }
15582 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
15583 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
15584 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
15585 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
15586 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
15587 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
15588 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
15589 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
15590 .field { min-width:0; }
15591 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
15592 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; }
15593 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); }
15594 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
15595 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); }
15596 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
15597 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
15598 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
15599 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15600 .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; }
15601 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
15602 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
15603 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
15604 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
15605 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
15606 .input-group.compact { grid-template-columns: 1fr auto auto; }
15607 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
15608 .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)); }
15609 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
15610 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
15611 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
15612 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
15613 .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; }
15614 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
15615 .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; }
15616 .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); }
15617 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
15618 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
15619 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
15620 button.secondary { background: var(--surface); }
15621 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15622 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
15623 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
15624 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15625 .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); }
15626 .section + .wizard-actions { border-top: none; padding-top: 0; }
15627 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
15628 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15629 .field-help-grid.coupled-help { margin-top: 12px; }
15630 .field-help-grid.preset-grid { align-items: start; }
15631 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
15632 .preset-inline-row .field { margin: 0; }
15633 .preset-inline-row .explainer-card { margin: 0; }
15634 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
15635 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
15636 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
15637 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
15638 .preset-kv-row > :last-child { flex:1; min-width:0; }
15639 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
15640 .output-field-row .field { margin: 0; }
15641 .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; }
15642 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
15643 .step3-subtitle { margin-bottom: 10px; max-width: none; }
15644 .counting-intro { margin-bottom: 8px; max-width: none; }
15645 .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; }
15646 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
15647 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
15648 .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; }
15649 .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; }
15650 .section-spacer-top { margin-top: 28px; }
15651 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
15652 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
15653 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
15654 .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); }
15655 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
15656 .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; }
15657 .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; }
15658 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
15659 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15660 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
15661 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
15662 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
15663 .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; }
15664 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
15665 .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); }
15666 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; }
15667 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; }
15668 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
15669 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
15670 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
15671 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
15672 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
15673 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
15674 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
15675 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
15676 .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); }
15677 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
15678 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
15679 .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; }
15680 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
15681 .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; }
15682 .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; }
15683 .always-tracked-tip-body { flex:1; min-width:0; }
15684 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
15685 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
15686 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
15687 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
15688 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
15689 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
15690 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
15691 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
15692 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
15693 .advanced-rule-description strong { color: var(--text); }
15694 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
15695 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
15696 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
15697 .review-link:hover { text-decoration: underline; }
15698 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
15699 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15700 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
15701 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
15702 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
15703 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
15704 .review-card ul { padding-left: 18px; margin: 0; }
15705 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
15706 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
15707 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
15708 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
15709 .review-card { min-height: 0; }
15710 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
15711 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
15712 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
15713 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
15714 .lang-overflow-chip { position:relative; cursor:default; }
15715 .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; }
15716 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
15717 .git-inline-row { align-items:start; }
15718 .mixed-line-card { display:flex; flex-direction:column; }
15719 .preset-inline-row .toggle-card { justify-content: center; }
15720 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
15721 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
15722 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
15723 .explorer-title { font-size: 18px; font-weight: 850; }
15724 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
15725 .explorer-subtitle.wide { max-width: none; }
15726 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
15727 .better-spacing { align-items:flex-start; justify-content:flex-end; }
15728 .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; }
15729 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
15730 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
15731 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
15732 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
15733 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
15734 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
15735 .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; }
15736 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
15737 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
15738 .scope-stat-button.supported { background: var(--success-bg); }
15739 .scope-stat-button.skipped { background: var(--warn-bg); }
15740 .scope-stat-button.unsupported { background: var(--danger-bg); }
15741 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
15742 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
15743 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
15744 [data-tooltip] { position: relative; }
15745 [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); }
15746 [data-tooltip]:hover::after { display: block; }
15747 .scope-stat-button[data-tooltip] { cursor: pointer; }
15748 .badge[data-tooltip] { cursor: help; }
15749 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
15750 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
15751 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
15752 .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; }
15753 .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; }
15754 code { display:inline-block; margin-top:0; padding:2px 7px; }
15755 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15756 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
15757 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
15758 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
15759 .language-pill.muted-pill { color: var(--muted); }
15760 button.language-pill { appearance:none; cursor:pointer; }
15761 .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); }
15762 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
15763 .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; }
15764 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
15765 .file-explorer-search-row { margin-left: auto; }
15766 .explorer-filter-select { min-width: 170px; width: 170px; }
15767 .explorer-search { min-width: 300px; width: 300px; }
15768 .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); }
15769 .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; }
15770 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
15771 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
15772 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
15773 .file-explorer-tree { max-height: 640px; overflow:auto; }
15774 .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); }
15775 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
15776 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
15777 .tree-row.hidden-by-filter { display:none !important; }
15778 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
15779 .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; }
15780 .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; }
15781 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
15782 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
15783 .tree-node { display:inline-flex; align-items:center; min-width:0; }
15784 .tree-node-dir { color: var(--text); font-weight: 800; }
15785 .tree-node-supported { color: var(--success-text); }
15786 .tree-node-skipped { color: var(--warn-text); }
15787 .tree-node-unsupported { color: var(--danger-text); }
15788 .tree-node-more { color: var(--muted-2); font-style: italic; }
15789 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
15790 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
15791 .tree-status-cell { display:flex; justify-content:flex-start; }
15792 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
15793 .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; }
15794 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15795 .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; }
15796 @keyframes prevSpin { to { transform:rotate(360deg); } }
15797 .preview-loading-text { flex:1; min-width:0; }
15798 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
15799 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
15800 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
15801 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
15802 .cov-scan-idle { display:none; }
15803 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
15804 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
15805 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
15806 .cov-scan-title { font-weight:600; font-size:12.5px; }
15807 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
15808 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
15809 .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; }
15810 .cov-scan-use:hover { opacity:.75; }
15811 .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; }
15812 .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; }
15813 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
15814 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
15815 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
15816 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
15817 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
15818 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
15819 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
15820 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
15821 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
15822 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
15823 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
15824 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
15825 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
15826 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
15827 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
15828 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
15829 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
15830 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
15831 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
15832 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
15833 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
15834 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
15835 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
15836 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
15837 .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); }
15838 .loading.active { display:flex; }
15839 .loading-card { 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; }
15840 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
15841 .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent-2), var(--oxide,#d37a4c)); animation: pulseBar 1.6s ease-in-out infinite; }
15842 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
15843 .lc-badge { display:inline-flex;align-items:center;gap:10px;background:linear-gradient(135deg,rgba(211,122,76,0.16),rgba(184,93,51,0.08));border:1.5px solid rgba(211,122,76,0.44);border-radius:10px;padding:8px 18px 8px 13px;font-size:12px;font-weight:800;color:var(--oxide,#d37a4c);text-transform:uppercase;letter-spacing:.07em;margin-bottom:20px;box-shadow:0 2px 16px rgba(211,122,76,0.16); }
15844 .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
15845 .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
15846 .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
15847 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
15848 @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
15849 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
15850 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
15851 .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; }
15852 .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
15853 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
15854 .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
15855 .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
15856 .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; }
15857 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
15858 .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; }
15859 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
15860 .lc-step.done { color:var(--muted);opacity:0.55; }
15861 .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; }
15862 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
15863 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
15864 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
15865 .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; }
15866 .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; }
15867 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
15868 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
15869 .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; }
15870 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
15871 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
15872 .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; }
15873 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
15874 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
15875 .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; }
15876 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
15877 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
15878 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
15879 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
15880 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
15881 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
15882 .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; }
15883 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
15884 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
15885 .hidden { display:none !important; }
15886 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15887 .site-footer a{color:var(--muted);}
15888 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
15889 @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; } }
15890 .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;}
15891 @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));}}
15892 .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;}
15893 .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; }
15894 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
15895 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
15896 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
15897 .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; }
15898 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
15899 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
15900 .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .18s ease; z-index:300; }
15901 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
15902 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
15903 .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; }
15904 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
15905 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
15906 .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; }
15907 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
15908 .info-icon-btn:hover { color:var(--text); }
15909 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); }
15910 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
15911 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
15912 .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;}
15913 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15914 .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;}
15915 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15916 #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);}
15917 #offline-file-banner.show{display:flex;}
15918 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
15919 #offline-file-banner .ofb-text{flex:1;}
15920 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
15921 #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;}
15922 #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;}
15923 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
15924 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
15925 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
15926 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
15927 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
15928 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
15929 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
15930 </style>
15931</head>
15932<body id="page-top">
15933 <div id="offline-file-banner" role="alert">
15934 <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>
15935 <span class="ofb-text">
15936 Charts, images, and navigation require the oxide-sloc server.
15937 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
15938 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
15939 The metric tables below are fully readable without the server.
15940 </span>
15941 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
15942 </div>
15943 <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>
15944 <div class="background-watermarks" aria-hidden="true">
15945 <img src="/images/logo/logo-text.png" alt="" />
15946 <img src="/images/logo/logo-text.png" alt="" />
15947 <img src="/images/logo/logo-text.png" alt="" />
15948 <img src="/images/logo/logo-text.png" alt="" />
15949 <img src="/images/logo/logo-text.png" alt="" />
15950 <img src="/images/logo/logo-text.png" alt="" />
15951 <img src="/images/logo/logo-text.png" alt="" />
15952 <img src="/images/logo/logo-text.png" alt="" />
15953 <img src="/images/logo/logo-text.png" alt="" />
15954 <img src="/images/logo/logo-text.png" alt="" />
15955 <img src="/images/logo/logo-text.png" alt="" />
15956 <img src="/images/logo/logo-text.png" alt="" />
15957 <img src="/images/logo/logo-text.png" alt="" />
15958 <img src="/images/logo/logo-text.png" alt="" />
15959 </div>
15960 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15961 <div class="top-nav">
15962 <div class="top-nav-inner">
15963 <a class="brand" href="/">
15964 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15965 <div class="brand-copy">
15966 <div class="brand-title">OxideSLOC</div>
15967 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15968 </div>
15969 </a>
15970 <div class="nav-project-slot">
15971 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
15972 <span class="nav-project-label">Project</span>
15973 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
15974 </div>
15975 </div>
15976 <div class="nav-status">
15977 <a class="nav-pill" href="/">Home</a>
15978 <div class="nav-dropdown">
15979 <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>
15980 <div class="nav-dropdown-menu">
15981 <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>
15982 </div>
15983 </div>
15984 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15985 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15986 <div class="nav-dropdown">
15987 <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>
15988 <div class="nav-dropdown-menu">
15989 <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>
15990 </div>
15991 </div>
15992 <div class="server-status-wrap" id="server-status-wrap">
15993 <div class="nav-pill server-online-pill" id="server-status-pill">
15994 <span class="status-dot" id="status-dot"></span>
15995 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
15996 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
15997 </div>
15998 <div class="server-status-tip">
15999 {% if server_mode %}
16000 OxideSLOC is running in server mode — accessible on your LAN.
16001 {% else %}
16002 OxideSLOC is running locally — only accessible from this machine.
16003 {% endif %}
16004 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16005 </div>
16006 </div>
16007 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16008 <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>
16009 </button>
16010 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16011 <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>
16012 <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>
16013 </button>
16014 </div>
16015 </div>
16016 </div>
16017
16018 <div class="loading" id="loading">
16019 <div class="loading-card">
16020 <div class="lc-badge" id="lc-badge"><span class="lc-dot-wrap"><span class="lc-dot"></span><span class="lc-dot-ring"></span></span>Analysis running</div>
16021 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
16022 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
16023 <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>
16024 <div class="lc-steps" id="lc-steps">
16025 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
16026 <div class="lc-step-arrow">›</div>
16027 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
16028 <div class="lc-step-arrow">›</div>
16029 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
16030 <div class="lc-step-arrow">›</div>
16031 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
16032 </div>
16033 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
16034 <div class="lc-metrics" id="lc-metrics">
16035 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
16036 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
16037 <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>
16038 <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>
16039 </div>
16040 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
16041 <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>
16042 <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>
16043 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
16044 <div class="lc-actions hidden" id="lc-actions">
16045 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
16046 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
16047 </div>
16048 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
16049 <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>
16050 Cancel scan
16051 </button>
16052 </div>
16053 </div>
16054
16055 <div class="page">
16056 <div class="workbench-strip">
16057 <div class="workbench-box wb-stats">
16058 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
16059 <span class="wb-stats-title">Analysis session</span>
16060 </div>
16061 <div class="ws-left">
16062 <div class="ws-stat ws-stat-analyzers">
16063 <span class="ws-label">Analyzers</span>
16064 <span class="ws-value">
16065 <span class="ws-badge">60 languages</span>
16066 </span>
16067 <div class="ws-lang-tooltip">
16068 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
16069 <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>
16070 <div class="ws-lang-grid">
16071 <span class="ws-lang-item">Assembly</span>
16072 <span class="ws-lang-item">C</span>
16073 <span class="ws-lang-item">C++</span>
16074 <span class="ws-lang-item">C#</span>
16075 <span class="ws-lang-item">Clojure</span>
16076 <span class="ws-lang-item">CSS</span>
16077 <span class="ws-lang-item">Dart</span>
16078 <span class="ws-lang-item">Dockerfile</span>
16079 <span class="ws-lang-item">Elixir</span>
16080 <span class="ws-lang-item">Erlang</span>
16081 <span class="ws-lang-item">F#</span>
16082 <span class="ws-lang-item">Go</span>
16083 <span class="ws-lang-item">Groovy</span>
16084 <span class="ws-lang-item">Haskell</span>
16085 <span class="ws-lang-item">HTML</span>
16086 <span class="ws-lang-item">Java</span>
16087 <span class="ws-lang-item">JavaScript</span>
16088 <span class="ws-lang-item">Julia</span>
16089 <span class="ws-lang-item">Kotlin</span>
16090 <span class="ws-lang-item">Lua</span>
16091 <span class="ws-lang-item">Makefile</span>
16092 <span class="ws-lang-item">Nim</span>
16093 <span class="ws-lang-item">Obj-C</span>
16094 <span class="ws-lang-item">OCaml</span>
16095 <span class="ws-lang-item">Perl</span>
16096 <span class="ws-lang-item">PHP</span>
16097 <span class="ws-lang-item">PowerShell</span>
16098 <span class="ws-lang-item">Python</span>
16099 <span class="ws-lang-item">R</span>
16100 <span class="ws-lang-item">Ruby</span>
16101 <span class="ws-lang-item">Rust</span>
16102 <span class="ws-lang-item">Scala</span>
16103 <span class="ws-lang-item">SCSS</span>
16104 <span class="ws-lang-item">Shell</span>
16105 <span class="ws-lang-item">SQL</span>
16106 <span class="ws-lang-item">Svelte</span>
16107 <span class="ws-lang-item">Swift</span>
16108 <span class="ws-lang-item">TypeScript</span>
16109 <span class="ws-lang-item">Vue</span>
16110 <span class="ws-lang-item">XML</span>
16111 <span class="ws-lang-item">Zig</span>
16112 <span class="ws-lang-item">Solidity</span>
16113 <span class="ws-lang-item">Protobuf</span>
16114 <span class="ws-lang-item">HCL</span>
16115 <span class="ws-lang-item">GraphQL</span>
16116 <span class="ws-lang-item">Ada</span>
16117 <span class="ws-lang-item">VHDL</span>
16118 <span class="ws-lang-item">Verilog</span>
16119 <span class="ws-lang-item">Tcl</span>
16120 <span class="ws-lang-item">Pascal</span>
16121 <span class="ws-lang-item">Visual Basic</span>
16122 <span class="ws-lang-item">Lisp</span>
16123 <span class="ws-lang-item">Fortran</span>
16124 <span class="ws-lang-item">Nix</span>
16125 <span class="ws-lang-item">Crystal</span>
16126 <span class="ws-lang-item">D</span>
16127 <span class="ws-lang-item">GLSL</span>
16128 <span class="ws-lang-item">CMake</span>
16129 <span class="ws-lang-item">Elm</span>
16130 <span class="ws-lang-item">Awk</span>
16131 </div>
16132 </div>
16133 </div>
16134 <div class="ws-divider"></div>
16135 <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>
16136 <div class="ws-divider"></div>
16137 <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts — JSON, HTML, and PDF reports — are written after each completed scan.">
16138 <span class="ws-label">Output</span>
16139 <span class="ws-value">
16140 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
16141 <span id="ws-output-root">project/sloc</span>
16142 </button>
16143 </span>
16144 </div>
16145 </div>
16146 </div>
16147 <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.">
16148 <div class="ws-history-label">Scan history</div>
16149 <div class="ws-history-inner">
16150 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
16151 <div class="ws-mini-label">Scans</div>
16152 <div class="ws-mini-value" id="ws-scan-count">—</div>
16153 </div>
16154 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
16155 <div class="ws-mini-label">Last Scan</div>
16156 <div class="ws-mini-value" id="ws-last-scan">—</div>
16157 </div>
16158 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
16159 <div class="ws-mini-label">Branch</div>
16160 <div class="ws-mini-value" id="ws-branch">—</div>
16161 </div>
16162 </div>
16163 </div>
16164 </div>
16165
16166 <div class="layout">
16167 <aside class="side-stack">
16168 <section class="step-nav">
16169 <h3>Guided scan setup</h3>
16170 <div class="sidebar-scroll-divider"></div>
16171 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
16172 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
16173 Top of page
16174 </a>
16175 <div class="sidebar-scroll-divider"></div>
16176 <button type="button" class="step-button active" 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>
16177 <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>
16178 <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>
16179 <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>
16180
16181 <div class="step-steps-divider"></div>
16182
16183 <div class="step-nav-info" id="step-nav-info">
16184 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
16185 <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>
16186 </div>
16187
16188 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
16189 <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>
16190 <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>
16191 <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>
16192 </div>
16193
16194 <div class="quick-scan-divider"></div>
16195 <div class="quick-scan-section">
16196 <div class="quick-scan-label">No customization needed?</div>
16197 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
16198 <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>
16199 Quick Scan
16200 </button>
16201 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
16202 </div>
16203
16204 <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>
16205 <div class="sidebar-scroll-divider"></div>
16206 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
16207 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
16208 Skip to bottom
16209 </a>
16210 </section>
16211
16212 </aside>
16213
16214 <section class="card">
16215 <div class="card-header">
16216 <div class="card-title-row">
16217 <div>
16218 <h1 class="card-title">Guided scan configuration</h1>
16219 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
16220 </div>
16221 <div class="wizard-progress" aria-label="Scan setup progress">
16222 <div class="wizard-progress-top">
16223 <span class="wizard-progress-label">Setup progress</span>
16224 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
16225 </div>
16226 <div class="wizard-progress-track">
16227 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
16228 </div>
16229 </div>
16230 </div>
16231 </div>
16232 <div class="card-body">
16233 <form method="post" action="/analyze" id="analyze-form">
16234 <div class="wizard-step active" data-step="1">
16235 <div class="section">
16236 <div class="section-kicker">Step 1</div>
16237 <h2>Select project and preview scope</h2>
16238 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
16239 <div class="field">
16240 <label for="path">Project path</label>
16241 {% if !git_repo.is_empty() %}
16242 <div class="git-source-banner">
16243 <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>
16244 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
16245 <a href="/git-browser">← Back to Git Browser</a>
16246 </div>
16247 {% endif %}
16248 <div class="path-scope-grid">
16249 {% if !git_repo.is_empty() %}
16250 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
16251 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
16252 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
16253 {% else %}
16254 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
16255 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
16256 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
16257 {% endif %}
16258 <div class="path-scope-sep"></div>
16259 <div class="scope-legend-row">
16260 <span class="scope-legend-label">Scope legend:</span>
16261 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
16262 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
16263 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
16264 </div>
16265 </div>
16266 {% if git_repo.is_empty() %}
16267 {% if server_mode %}
16268 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
16269 ℹ️ Files are compressed and streamed — no fixed size limit.
16270 </div>
16271 {% endif %}
16272 <div class="path-info-row">
16273 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
16274 <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>
16275 <span id="project-size-text">Project size: —</span>
16276 </button>
16277 </div>
16278 {% else %}
16279 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
16280 {% endif %}
16281 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
16282 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
16283 </div>
16284
16285 <div class="scope-preview-divider" aria-hidden="true"></div>
16286
16287 <div id="preview-panel">
16288 <div class="preview-error">Loading preview...</div>
16289 </div>
16290 </div>
16291
16292 <div class="section" style="margin-top:14px;">
16293 <div class="preset-inline-row git-inline-row">
16294 <div class="toggle-card" style="margin:0;">
16295 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
16296 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
16297 <label class="checkbox">
16298 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
16299 <div>
16300 <span>Detect and separate git submodules</span>
16301 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
16302 </div>
16303 </label>
16304 </div>
16305 <div class="explainer-card prominent" style="margin:0;">
16306 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16307 <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>
16308 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
16309 path = libs/core
16310 url = https://github.com/org/core.git
16311
16312[submodule "libs/ui"]
16313 path = libs/ui
16314 url = https://github.com/org/ui.git</div>
16315 </div>
16316 </div>
16317 </div>
16318
16319 <div class="section">
16320 <div class="field-grid">
16321 <div class="field">
16322 <div class="glob-label-row">
16323 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
16324 <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>
16325 </div>
16326 <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>
16327 <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>
16328 </div>
16329 <div class="field">
16330 <div class="glob-label-row">
16331 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
16332 </div>
16333 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
16334 <div id="quick-exclude-chips" class="quick-excl-row">
16335 <span class="quick-excl-label">Quick add:</span>
16336 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
16337 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
16338 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
16339 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
16340 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
16341 <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>
16342 </div>
16343 <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>
16344 </div>
16345 </div>
16346 <div class="glob-guidance-grid">
16347 <div class="glob-guidance-card">
16348 <strong>How to read them</strong>
16349 <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>
16350 </div>
16351 <div class="glob-guidance-card">
16352 <strong>Common include examples</strong>
16353 <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>
16354 </div>
16355 <div class="glob-guidance-card">
16356 <strong>Common exclude examples</strong>
16357 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
16358 </div>
16359 </div>
16360 </div>
16361
16362 <div class="section" style="margin-top:14px;">
16363 <div class="preset-inline-row git-inline-row">
16364 <div class="toggle-card" style="margin:0;">
16365 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
16366 <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>
16367 <div class="field" style="margin:0;">
16368 <div class="input-group compact">
16369 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
16370 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
16371 </div>
16372 <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>
16373 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
16374 </div>
16375 </div>
16376 <div class="explainer-card prominent" style="margin:0;">
16377 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16378 <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>
16379 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
16380lcov --capture --directory . --output-file coverage/lcov.info
16381
16382# C / C++ — llvm-cov (LCOV)
16383llvm-profdata merge -sparse default.profraw -o default.profdata
16384llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
16385
16386# C# — coverlet (Cobertura XML)
16387dotnet test --collect:"XPlat Code Coverage"
16388
16389# Python — pytest-cov (Cobertura XML)
16390pytest --cov --cov-report=xml
16391
16392# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
16393./gradlew jacocoTestReport</div>
16394 </div>
16395 </div>
16396 </div>
16397
16398 <div class="wizard-actions">
16399 <div class="left"></div>
16400 <div class="right">
16401 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
16402 </div>
16403 </div>
16404 </div>
16405
16406 <div class="wizard-step" data-step="2">
16407 <div class="section">
16408 <div class="section-kicker">Step 2</div>
16409 <h2>Choose counting behavior</h2>
16410 <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>
16411<div class="subsection-bar">Primary line classification</div>
16412 <div class="preset-kv-row">
16413 <div class="toggle-card mixed-line-card" style="margin:0;">
16414 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
16415 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
16416 <select id="mixed_line_policy" name="mixed_line_policy">
16417 <option value="code_only">Code only</option>
16418 <option value="code_and_comment">Code and comment</option>
16419 <option value="comment_only">Comment only</option>
16420 <option value="separate_mixed_category">Separate mixed category</option>
16421 </select>
16422 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
16423 </div>
16424 <div class="explainer-card prominent" style="margin:0;">
16425 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
16426 <div class="explainer-body" id="mixed-policy-description"></div>
16427 <div class="code-sample" id="mixed-policy-example"></div>
16428 </div>
16429 </div>
16430 </div>
16431
16432 <div class="subsection-bar">Additional scan rules</div>
16433 <div class="scan-rules-grid">
16434 <div class="preset-inline-row">
16435 <div class="toggle-card" style="margin:0;">
16436 <div class="field-help-title">Generated files</div>
16437 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
16438 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16439 </div>
16440 <div class="explainer-card prominent" style="margin:0;">
16441 <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>
16442 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
16443# Files matching codegen patterns are excluded:
16444# *.generated.cs *.pb.go *.g.dart</div>
16445 </div>
16446 </div>
16447 <div class="preset-inline-row">
16448 <div class="toggle-card" style="margin:0;">
16449 <div class="field-help-title">Minified files</div>
16450 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
16451 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16452 </div>
16453 <div class="explainer-card prominent" style="margin:0;">
16454 <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>
16455 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
16456# Heuristic: very long lines + low whitespace ratio
16457# jquery.min.js bundle.min.css → skipped</div>
16458 </div>
16459 </div>
16460 <div class="preset-inline-row">
16461 <div class="toggle-card" style="margin:0;">
16462 <div class="field-help-title">Vendor directories</div>
16463 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
16464 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16465 </div>
16466 <div class="explainer-card prominent" style="margin:0;">
16467 <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>
16468 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
16469# Directories named vendor/ node_modules/ third_party/
16470# → entire subtree is excluded from totals</div>
16471 </div>
16472 </div>
16473 <div class="preset-inline-row">
16474 <div class="toggle-card" style="margin:0;">
16475 <div class="field-help-title">Lockfiles and manifests</div>
16476 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
16477 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
16478 </div>
16479 <div class="explainer-card prominent" style="margin:0;">
16480 <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>
16481 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
16482# Files like package-lock.json Cargo.lock yarn.lock
16483# → skipped unless this is enabled</div>
16484 </div>
16485 </div>
16486 <div class="preset-inline-row">
16487 <div class="toggle-card" style="margin:0;">
16488 <div class="field-help-title">Binary handling</div>
16489 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
16490 <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>
16491 </div>
16492 <div class="explainer-card prominent" style="margin:0;">
16493 <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>
16494 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
16495# Detected via long lines + low whitespace heuristic
16496# .png .exe .so → skipped silently</div>
16497 </div>
16498 </div>
16499 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
16500 <div class="toggle-card" style="margin:0;">
16501 <div class="field-help-title">Python docstrings</div>
16502 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
16503 <label class="checkbox">
16504 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
16505 <span>Count as comment-style lines</span>
16506 </label>
16507 </div>
16508 <div class="explainer-card prominent" style="margin:0;">
16509 <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>
16510 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
16511 </div>
16512 </div>
16513 </div>
16514 <div class="subsection-bar">IEEE 1045-1992 counting</div>
16515 <div class="scan-rules-grid">
16516 <div class="preset-inline-row">
16517 <div class="toggle-card" style="margin:0;">
16518 <div class="field-help-title">Continuation lines</div>
16519 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
16520 <select name="continuation_line_policy" id="continuation_line_policy">
16521 <option value="each_physical_line" selected>Each physical line (default)</option>
16522 <option value="collapse_to_logical">Collapse to logical line</option>
16523 </select>
16524 </div>
16525 <div class="explainer-card prominent" style="margin:0;">
16526 <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>
16527 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
16528 ((a) > (b) ? (a) : (b))
16529# each_physical_line → 2 SLOC
16530# collapse_to_logical → 1 SLOC</div>
16531 </div>
16532 </div>
16533 <div class="preset-inline-row">
16534 <div class="toggle-card" style="margin:0;">
16535 <div class="field-help-title">Block-comment blanks</div>
16536 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
16537 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
16538 <option value="count_as_comment" selected>Count as comment (default)</option>
16539 <option value="count_as_blank">Count as blank</option>
16540 </select>
16541 </div>
16542 <div class="explainer-card prominent" style="margin:0;">
16543 <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>
16544 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
16545 * Summary line
16546 * ← blank inside block comment
16547 * Detail line
16548 */
16549# count_as_comment → blank counts toward comments
16550# count_as_blank → blank counts toward blanks</div>
16551 </div>
16552 </div>
16553 <div class="preset-inline-row">
16554 <div class="toggle-card" style="margin:0;">
16555 <div class="field-help-title">Compiler directives</div>
16556 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
16557 <select name="count_compiler_directives" id="count_compiler_directives">
16558 <option value="enabled" selected>Include in code SLOC (default)</option>
16559 <option value="disabled">Exclude from code SLOC</option>
16560 </select>
16561 </div>
16562 <div class="explainer-card prominent" style="margin:0;">
16563 <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>
16564 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
16565#define BUF 256 ← compiler directive
16566int main() { … } ← code
16567# enabled → 3 code SLOC
16568# disabled → 1 code SLOC + 2 directive lines</div>
16569 </div>
16570 </div>
16571 </div>
16572
16573 <div class="subsection-bar">Code Style Analysis</div>
16574 <div class="scan-rules-grid">
16575 <div class="preset-inline-row">
16576 <div class="toggle-card" style="margin:0;">
16577 <div class="field-help-title">Style analysis</div>
16578 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
16579 <select name="style_analysis_enabled" id="style_analysis_enabled">
16580 <option value="enabled" selected>Enabled (default)</option>
16581 <option value="disabled">Disabled — skip style scoring</option>
16582 </select>
16583 </div>
16584 <div class="explainer-card prominent" style="margin:0;">
16585 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
16586 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
16587# style_analysis_enabled = false (skip, faster scan)
16588# Disabling removes the Code Style section from the report.</div>
16589 </div>
16590 </div>
16591 <div class="preset-inline-row">
16592 <div class="toggle-card" style="margin:0;">
16593 <div class="field-help-title">Column-width threshold</div>
16594 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
16595 <select name="style_col_threshold" id="style_col_threshold">
16596 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
16597 <option value="100">100 columns (Uber Go, Google Java)</option>
16598 <option value="120">120 columns (Uber Go max, Kotlin)</option>
16599 </select>
16600 </div>
16601 <div class="explainer-card prominent" style="margin:0;">
16602 <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>
16603 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
16604# style_col_threshold = 100 (Uber Go, Google Java)
16605# style_col_threshold = 120 (Uber Go max, Kotlin)
16606# Files where <= 5% of lines exceed the limit
16607# are counted as "N-col compliant" in the report.</div>
16608 </div>
16609 </div>
16610 <div class="preset-inline-row">
16611 <div class="toggle-card" style="margin:0;">
16612 <div class="field-help-title">Score alert threshold</div>
16613 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
16614 <select name="style_score_threshold" id="style_score_threshold">
16615 <option value="0" selected>Off — no threshold (default)</option>
16616 <option value="40">40% — flag poorly styled files</option>
16617 <option value="50">50% — flag below-average files</option>
16618 <option value="60">60% — flag below-good files</option>
16619 <option value="70">70% — flag below-strong files</option>
16620 </select>
16621 </div>
16622 <div class="explainer-card prominent" style="margin:0;">
16623 <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>
16624 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
16625# style_score_threshold = 50 (flag files < 50%)
16626# Low-scoring files get a red left-border in the
16627# per-file style breakdown table.</div>
16628 </div>
16629 </div>
16630 </div>
16631
16632 <div class="always-tracked-tip">
16633 <div class="always-tracked-tip-icon">ℹ</div>
16634 <div class="always-tracked-tip-body">
16635 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
16636 <h4>Comment and blank-line basics & Lines on the boundary</h4>
16637 <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>
16638 </div>
16639 </div>
16640
16641 <div class="subsection-bar">Advanced Metrics</div>
16642 <div class="scan-rules-grid">
16643 <div class="preset-inline-row">
16644 <div class="toggle-card" style="margin:0;">
16645 <div class="field-help-title">COCOMO mode</div>
16646 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
16647 <select name="cocomo_mode" id="cocomo_mode">
16648 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
16649 <option value="semi_detached">Semi-detached — mixed constraints</option>
16650 <option value="embedded">Embedded — tight hardware/OS constraints</option>
16651 </select>
16652 </div>
16653 <div class="explainer-card prominent" style="margin:0;">
16654 <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>
16655 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
16656# Semi-detached: Effort = 3.0 × KSLOC^1.12
16657# Embedded: Effort = 3.6 × KSLOC^1.20
16658# All modes: Schedule = 2.5 × Effort^d</div>
16659 </div>
16660 </div>
16661 <div class="preset-inline-row">
16662 <div class="toggle-card" style="margin:0;">
16663 <div class="field-help-title">Complexity alert</div>
16664 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
16665 <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;" />
16666 </div>
16667 <div class="explainer-card prominent" style="margin:0;">
16668 <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>
16669 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
16670# 50 = flag any file with > 50 branch points
16671# 100 = flag any file with > 100 branch points
16672# Files above the threshold are highlighted
16673# in the result page metric strip.</div>
16674 </div>
16675 </div>
16676 <div class="preset-inline-row">
16677 <div class="toggle-card" style="margin:0;">
16678 <div class="field-help-title">Duplicate handling</div>
16679 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
16680 <select name="exclude_duplicates" id="exclude_duplicates">
16681 <option value="disabled" selected>Detect and report only (default)</option>
16682 <option value="enabled">Detect and exclude from SLOC totals</option>
16683 </select>
16684 </div>
16685 <div class="explainer-card prominent" style="margin:0;">
16686 <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>
16687 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
16688# detect only → all 3 counted in SLOC
16689# exclude dupes → 1 counted, 2 excluded
16690# Duplicate groups chip always shows the count.</div>
16691 </div>
16692 </div>
16693 <div class="always-tracked-tip" style="margin:8px 0 0;">
16694 <div class="always-tracked-tip-icon">ℹ</div>
16695 <div class="always-tracked-tip-body">
16696 <div class="field-help-title">Always computed — every scan produces these automatically</div>
16697 <div class="always-tracked-metrics-row">
16698 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
16699 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
16700 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
16701 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
16702 </div>
16703 <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>
16704 </div>
16705 </div>
16706 </div>
16707
16708 <div class="wizard-actions">
16709 <div class="left">
16710 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
16711 </div>
16712 <div class="right">
16713 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
16714 </div>
16715 </div>
16716 </div>
16717
16718 <div class="wizard-step" data-step="3">
16719 <div class="section">
16720 <div class="section-kicker">Step 3</div>
16721 <h2>Output and report identity</h2>
16722 <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>
16723 <div class="preset-kv-row">
16724 <div class="toggle-card" style="margin:0;">
16725 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
16726 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
16727 <select id="scan_preset">
16728 <option value="balanced">Balanced local scan</option>
16729 <option value="code_focused">Code focused</option>
16730 <option value="comment_audit">Comment audit</option>
16731 <option value="deep_review">Deep review</option>
16732 </select>
16733 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
16734 </div>
16735 <div class="explainer-card">
16736 <div class="field-help-title">Selected scan preset</div>
16737 <div class="explainer-body" id="scan-preset-description"></div>
16738 <div class="preset-summary-row" id="scan-preset-summary"></div>
16739 <div class="code-sample" id="scan-preset-example"></div>
16740 <div class="preset-note" id="scan-preset-note"></div>
16741 </div>
16742 </div>
16743 <hr class="step3-separator" />
16744 <div class="preset-kv-row">
16745 <div class="toggle-card" style="margin:0;">
16746 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
16747 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
16748 <select id="artifact_preset">
16749 <option value="review">Review bundle</option>
16750 <option value="full">Full bundle</option>
16751 <option value="html_only">HTML only</option>
16752 <option value="machine">Machine bundle</option>
16753 </select>
16754 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
16755 </div>
16756 <div class="explainer-card">
16757 <div class="field-help-title">Selected artifact preset</div>
16758 <div class="explainer-body" id="artifact-preset-description"></div>
16759 <div class="preset-summary-row" id="artifact-preset-summary"></div>
16760 <div class="code-sample" id="artifact-preset-example"></div>
16761 </div>
16762 </div>
16763 </div>
16764
16765 <div class="section section-spacer-top">
16766 <div class="output-field-row">
16767 <div class="field">
16768 <label for="output_dir">Output directory</label>
16769 {% if server_mode %}
16770 <div class="input-group compact">
16771 <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);" />
16772 </div>
16773 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
16774 {% else %}
16775 <div class="input-group compact">
16776 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
16777 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
16778 <button type="button" class="mini-button" id="use-default-output">Use default</button>
16779 </div>
16780 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
16781 {% endif %}
16782 </div>
16783 <div class="output-field-aside">
16784 <strong>Where reports land</strong>
16785 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.
16786 </div>
16787 </div>
16788 </div>
16789
16790 <div class="section section-spacer-top">
16791 <div class="output-field-row">
16792 <div class="field">
16793 <label for="report_title">Report title</label>
16794 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
16795 <div class="hint">Appears in HTML and PDF output headers.</div>
16796 </div>
16797 <div class="output-field-aside">
16798 <strong>Shown in exported artifacts</strong>
16799 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.
16800 </div>
16801 </div>
16802 </div>
16803
16804 <div class="section section-spacer-top">
16805 <div class="output-field-row">
16806 <div class="field">
16807 <label for="report_header_footer">Report header / footer</label>
16808 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
16809 <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>
16810 </div>
16811 <div class="output-field-aside">
16812 <strong>Page-level identification</strong>
16813 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.
16814 </div>
16815 </div>
16816 </div>
16817
16818 <div class="wizard-actions">
16819 <div class="left">
16820 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
16821 </div>
16822 <div class="right">
16823 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
16824 </div>
16825 </div>
16826 </div>
16827
16828 <div class="wizard-step" data-step="4">
16829 <div class="section">
16830 <div class="section-kicker">Step 4</div>
16831 <h2>Review selections and run</h2>
16832 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
16833 <div class="review-grid">
16834 <div class="review-card highlight">
16835 <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>
16836 <ul id="review-scan-summary"></ul>
16837 </div>
16838 <div class="review-card highlight">
16839 <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>
16840 <ul id="review-count-summary"></ul>
16841 </div>
16842 <div class="review-card">
16843 <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>
16844 <ul id="review-artifact-summary"></ul>
16845 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
16846 </div>
16847 <div class="review-card">
16848 <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>
16849 <ul id="review-preview-summary"></ul>
16850 </div>
16851 </div>
16852 </div>
16853
16854 <div class="wizard-actions">
16855 <div class="left">
16856 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
16857 </div>
16858 <div class="right">
16859 <button type="submit" id="submit-button" class="primary">Run analysis</button>
16860 </div>
16861 </div>
16862 </div>
16863 {% if server_mode %}
16864 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
16865 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
16866 {% endif %}
16867 </form>
16868 </div>
16869 </section>
16870 </div>
16871 </div>
16872
16873 <script nonce="{{ csp_nonce }}">
16874 (function () {
16875 function startScanPhase() {
16876 var phaseEl = document.getElementById("scan-phase");
16877 if (!phaseEl) return;
16878 var phases = [
16879 "Discovering files...",
16880 "Decoding file encodings...",
16881 "Detecting languages...",
16882 "Analyzing source lines...",
16883 "Applying counting policies...",
16884 "Aggregating results...",
16885 "Rendering report..."
16886 ];
16887 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
16888 var i = 0;
16889 function next() {
16890 phaseEl.style.opacity = "0";
16891 setTimeout(function () {
16892 phaseEl.textContent = phases[i];
16893 phaseEl.style.opacity = "0.85";
16894 var delay = durations[i] || 1800;
16895 i++;
16896 if (i < phases.length) { setTimeout(next, delay); }
16897 }, 200);
16898 }
16899 next();
16900 }
16901
16902 var form = document.getElementById("analyze-form");
16903 var loading = document.getElementById("loading");
16904 var submitButton = document.getElementById("submit-button");
16905 var pathInput = document.getElementById("path");
16906 var GIT_MODE = !!(pathInput && pathInput.readOnly);
16907 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
16908 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
16909 var outputDirInput = document.getElementById("output_dir");
16910 var reportTitleInput = document.getElementById("report_title");
16911 var previewPanel = document.getElementById("preview-panel");
16912 var refreshButton = document.getElementById("refresh-preview");
16913 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
16914 var useSamplePath = document.getElementById("use-sample-path");
16915 var useDefaultOutput = document.getElementById("use-default-output");
16916 var browsePath = document.getElementById("browse-path");
16917 var browseOutputDir = document.getElementById("browse-output-dir");
16918 var browseCoverage = document.getElementById("browse-coverage");
16919 var coverageInput = document.getElementById("coverage_file");
16920 var covScanStatus = document.getElementById("cov-scan-status");
16921 var coverageSuggestTimer = null;
16922 var covAutoFilled = false;
16923 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
16924
16925 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
16926 (function() {
16927 var ids = ["path", "output_dir"];
16928 ids.forEach(function(id) {
16929 var el = document.getElementById(id);
16930 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
16931 });
16932 }());
16933 function fmtBytes(b) {
16934 b = Number(b) || 0;
16935 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
16936 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
16937 if (b >= 1024) return Math.round(b / 1024) + ' KB';
16938 return b + ' B';
16939 }
16940 var themeToggle = document.getElementById("theme-toggle");
16941
16942 function showBannerToast(msg, isError, opts) {
16943 opts = opts || {};
16944 var t = document.createElement('div');
16945 t.className = isError ? 'toast-error' : 'toast-success';
16946 var topPos = opts.top ? '80px' : null;
16947 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
16948 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
16949 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
16950 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
16951 if (opts.icon) {
16952 var inner = document.createElement('span');
16953 inner.innerHTML = opts.icon + ' ';
16954 t.appendChild(inner);
16955 }
16956 t.appendChild(document.createTextNode(msg));
16957 document.body.appendChild(t);
16958 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
16959 }
16960 var mixedLinePolicy = document.getElementById("mixed_line_policy");
16961 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
16962 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
16963 var scanPreset = document.getElementById("scan_preset");
16964 var artifactPreset = document.getElementById("artifact_preset");
16965 var includeGlobsInput = document.getElementById("include_globs");
16966 var excludeGlobsInput = document.getElementById("exclude_globs");
16967
16968 // Include globs scope badge — updates reactively as the user types.
16969 (function() {
16970 var badge = document.getElementById("include-scope-badge");
16971 if (!badge || !includeGlobsInput) return;
16972 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> ';
16973 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> ';
16974 function update() {
16975 var val = includeGlobsInput.value.trim();
16976 if (!val) {
16977 badge.className = "include-scope-badge scope-all";
16978 badge.innerHTML = iconCheck + "All files eligible — no include filter active";
16979 } else {
16980 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
16981 badge.className = "include-scope-badge scope-narrow";
16982 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " — only matching files will be included";
16983 }
16984 }
16985 includeGlobsInput.addEventListener("input", update);
16986 update();
16987 }());
16988
16989 // Quick-exclude chips — append pattern to exclude_globs textarea.
16990 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
16991 chip.addEventListener("click", function() {
16992 var pattern = chip.getAttribute("data-pattern") || "";
16993 if (!pattern || !excludeGlobsInput) return;
16994 var current = excludeGlobsInput.value.trim();
16995 // For the "skip all" chip, replace any existing dep patterns cleanly.
16996 var patterns = pattern.split("\n");
16997 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
16998 var added = false;
16999 patterns.forEach(function(p) {
17000 p = p.trim();
17001 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
17002 });
17003 if (added) {
17004 excludeGlobsInput.value = lines.join("\n");
17005 excludeGlobsInput.dispatchEvent(new Event("input"));
17006 }
17007 chip.classList.add("active");
17008 });
17009 });
17010
17011 var liveReportTitle = document.getElementById("live-report-title");
17012 var navProjectPill = document.getElementById("nav-project-pill");
17013 var navProjectTitle = document.getElementById("nav-project-title");
17014 var reportTitlePreview = null;
17015 var wizardProgressFill = document.getElementById("wizard-progress-fill");
17016 var wizardProgressValue = document.getElementById("wizard-progress-value");
17017 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
17018 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
17019 var reportTitleTouched = false;
17020 var currentStep = 1;
17021 var previewTimer = null;
17022 var _previewGen = 0;
17023 var quickScanBtn = document.getElementById("quick-scan-btn");
17024
17025 function dismissAnalysisModal() {
17026 if (loading) loading.classList.remove("active");
17027 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17028 var el = document.getElementById(id);
17029 if (el) el.classList.add("hidden");
17030 });
17031 var cancelBtn = document.getElementById("lc-cancel-btn");
17032 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
17033 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
17034 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
17035 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
17036 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");}
17037 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
17038 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17039 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17040 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17041 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17042 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17043 }
17044
17045 var lcDismissBtn = document.getElementById("lc-dismiss");
17046 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
17047
17048 // When the browser restores this page from bfcache (Back button after navigating to results),
17049 // the loading overlay would still be showing its active state. Dismiss it immediately.
17050 window.addEventListener("pageshow", function(e) {
17051 if (e.persisted) { dismissAnalysisModal(); }
17052 });
17053
17054 function startAsyncAnalysis(formData) {
17055 var gitRepo = (formData.get("git_repo") || "").toString();
17056 var gitRef = (formData.get("git_ref") || "").toString();
17057 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
17058 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
17059
17060 var pathEl = document.getElementById("lc-path-text");
17061 if (pathEl) pathEl.textContent = displayPath;
17062
17063 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17064 var el = document.getElementById(id);
17065 if (el) el.classList.add("hidden");
17066 });
17067 var cancelBtn = document.getElementById("lc-cancel-btn");
17068 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
17069 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17070 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17071 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17072 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
17073 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
17074 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
17075 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");}
17076 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
17077
17078 if (loading) loading.classList.add("active");
17079
17080 var startTime = Date.now();
17081 var elapsedTimer = setInterval(function() {
17082 var s = Math.floor((Date.now() - startTime) / 1000);
17083 var el = document.getElementById("lc-elapsed");
17084 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
17085 }, 1000);
17086
17087 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
17088
17089 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();}
17090
17091 var PHASE_DESC = {
17092 'Starting': 'Initializing language analyzers and loading configuration…',
17093 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
17094 'Running': 'Running the lexical state machine across all discovered source files…',
17095 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
17096 'Done': 'Analysis complete — loading your results…',
17097 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
17098 };
17099 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
17100 function lcSetPhase(txt) {
17101 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
17102 var desc = document.getElementById("lc-stage-desc");
17103 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
17104 var step = PHASE_STEP[txt] || 1;
17105 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");}
17106 }
17107
17108 function lcShowCancelled() {
17109 clearInterval(elapsedTimer);
17110 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
17111 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
17112 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
17113 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
17114 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
17115 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
17116 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
17117 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
17118 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17119 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17120 }
17121
17122 var lcCancelBtn = document.getElementById("lc-cancel-btn");
17123 if (lcCancelBtn) {
17124 lcCancelBtn.onclick = function() {
17125 if (!activeWaitId) { dismissAnalysisModal(); return; }
17126 lcCancelBtn.disabled = true;
17127 lcCancelBtn.textContent = "Cancelling…";
17128 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
17129 .then(function() { lcShowCancelled(); })
17130 .catch(function() { lcShowCancelled(); });
17131 };
17132 }
17133
17134 function lcShowError(msg) {
17135 clearInterval(elapsedTimer);
17136 lcSetPhase("Failed");
17137 var msgEl = document.getElementById("lc-err-msg");
17138 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
17139 var errEl = document.getElementById("lc-err");
17140 var actEl = document.getElementById("lc-actions");
17141 if (errEl) errEl.classList.remove("hidden");
17142 if (actEl) actEl.classList.remove("hidden");
17143 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17144 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17145 }
17146
17147 function lcPoll(waitId) {
17148 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
17149 .then(function(r) {
17150 if (!r.ok) throw new Error("HTTP " + r.status);
17151 return r.json();
17152 })
17153 .then(function(data) {
17154 pollRetries = 0;
17155 if (data.state === "complete") {
17156 clearInterval(elapsedTimer);
17157 lcSetPhase("Done");
17158 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
17159 } else if (data.state === "failed") {
17160 lcShowError(data.message);
17161 } else if (data.state === "cancelled") {
17162 lcShowCancelled();
17163 } else {
17164 var s = Math.floor((Date.now() - startTime) / 1000);
17165 if (s > 90 && !warnShown) {
17166 warnShown = true;
17167 var w = document.getElementById("lc-warn");
17168 if (w) w.classList.remove("hidden");
17169 }
17170 lcSetPhase(data.phase || "Running");
17171 var fd = data.files_done || 0, ft = data.files_total || 0;
17172 if (ft > 0) {
17173 var card = document.getElementById("lc-files-card");
17174 if (card) card.classList.remove("hidden");
17175 var el = document.getElementById("lc-files");
17176 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
17177 var now = Date.now();
17178 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
17179 if (fdelta > 0 && tdelta > 0.4) {
17180 var fps = Math.round(fdelta / tdelta);
17181 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
17182 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
17183 }
17184 lastFd = fd; lastFdTime = now;
17185 }
17186 setTimeout(function() { lcPoll(waitId); }, 1500);
17187 }
17188 })
17189 .catch(function() {
17190 pollRetries++;
17191 if (pollRetries >= 5) {
17192 lcShowError("Lost connection to server. Reload to check status.");
17193 } else {
17194 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
17195 }
17196 });
17197 }
17198
17199 var params = new URLSearchParams(formData);
17200 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
17201 .then(function(r) {
17202 var waitId = r.headers.get("x-wait-id");
17203 if (!waitId) { window.location.href = "/scan"; return; }
17204 activeWaitId = waitId;
17205 setTimeout(function() { lcPoll(waitId); }, 1500);
17206 })
17207 .catch(function(err) {
17208 lcShowError("Could not reach server: " + (err.message || err));
17209 });
17210 }
17211
17212 if (quickScanBtn) {
17213 quickScanBtn.addEventListener("click", function () {
17214 var pathVal = pathInput ? pathInput.value.trim() : "";
17215 if (!pathVal) {
17216 alert("Please enter or browse to a project path first.");
17217 return;
17218 }
17219 quickScanBtn.disabled = true;
17220 quickScanBtn.textContent = "Scanning...";
17221 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
17222 startAsyncAnalysis(new FormData(form));
17223 });
17224 }
17225
17226 var mixedPolicyInfo = {
17227 code_only: {
17228 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.",
17229 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'
17230 },
17231 code_and_comment: {
17232 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.",
17233 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'
17234 },
17235 comment_only: {
17236 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.",
17237 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'
17238 },
17239 separate_mixed_category: {
17240 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.",
17241 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'
17242 }
17243 };
17244
17245 var scanPresetInfo = {
17246 balanced: {
17247 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.",
17248 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
17249 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
17250 note: "Best when you want a stable local overview before making deeper adjustments.",
17251 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17252 },
17253 code_focused: {
17254 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
17255 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
17256 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
17257 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
17258 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17259 },
17260 comment_audit: {
17261 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
17262 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
17263 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
17264 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
17265 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17266 },
17267 deep_review: {
17268 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
17269 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
17270 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
17271 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
17272 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
17273 }
17274 };
17275
17276 var artifactPresetInfo = {
17277 review: {
17278 description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
17279 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
17280 example: "Ideal for a quick local review before sharing results."
17281 },
17282 full: {
17283 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
17284 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
17285 example: "Use when producing a deliverable or storing a snapshot for future comparison."
17286 },
17287 html_only: {
17288 description: "Standalone HTML report only. No PDF generation, no data files.",
17289 chips: ["HTML only"],
17290 example: "Fastest option when you only need to open the report in a browser."
17291 },
17292 machine: {
17293 description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
17294 chips: ["JSON", "CSV", "no HTML", "no PDF"],
17295 example: "Use in CI to capture metrics without generating visual reports."
17296 }
17297 };
17298
17299 function applyArtifactPreset() {
17300 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
17301 if (!info) return;
17302 var descEl = document.getElementById("artifact-preset-description");
17303 var exampleEl = document.getElementById("artifact-preset-example");
17304 if (descEl) descEl.textContent = info.description;
17305 if (exampleEl) exampleEl.textContent = info.example;
17306 renderPresetChips("artifact-preset-summary", info.chips);
17307 }
17308
17309 function applyTheme(theme) {
17310 if (theme === "dark") document.body.classList.add("dark-theme");
17311 else document.body.classList.remove("dark-theme");
17312 }
17313
17314 function loadSavedTheme() {
17315 var saved = null;
17316 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
17317 applyTheme(saved === "dark" ? "dark" : "light");
17318 }
17319
17320 function updateScrollProgress() {
17321 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
17322 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
17323 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
17324 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
17325 var step = Math.min(Math.max(currentStep, 1), 4);
17326 var base = stepBase[step];
17327 var end = stepEnd[step];
17328
17329 var scrollFrac = 0;
17330 var activePanel = document.querySelector(".wizard-step.active");
17331 if (activePanel) {
17332 var scrollTop = window.scrollY || window.pageYOffset || 0;
17333 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
17334 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
17335 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
17336 var scrolled = scrollTop + viewH - panelTop;
17337 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
17338 }
17339
17340 var percent = Math.round(base + (end - base) * scrollFrac);
17341 percent = Math.min(end, Math.max(base, percent));
17342 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
17343 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
17344 }
17345
17346 function updateWizardProgress() {
17347 updateScrollProgress();
17348 }
17349
17350 var stepDescriptions = [
17351 "Choose a project folder, apply scope filters, and preview which files will be counted.",
17352 "Configure how mixed code-plus-comment lines and docstrings are classified.",
17353 "Pick your output formats, scan preset, and where reports are saved.",
17354 "Review all settings and launch the analysis."
17355 ];
17356
17357 function updateStepNav(step) {
17358 var infoLabel = document.getElementById("step-nav-info-label");
17359 var infoDesc = document.getElementById("step-nav-info-desc");
17360 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
17361 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
17362 }
17363
17364 function updateSidebarSummary() {
17365 var sumPath = document.getElementById("sum-path");
17366 var sumPreset = document.getElementById("sum-preset");
17367 var sumOutput = document.getElementById("sum-output");
17368 var sidebarSummary = document.getElementById("sidebar-summary");
17369 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
17370 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
17371 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
17372 if (sumPath) sumPath.textContent = pathVal || "—";
17373 if (sumPreset) sumPreset.textContent = presetVal || "—";
17374 if (sumOutput) sumOutput.textContent = outputVal || "—";
17375 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
17376 }
17377
17378 function setStep(step, pushHistory) {
17379 currentStep = step;
17380 stepPanels.forEach(function (panel) {
17381 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
17382 });
17383 stepButtons.forEach(function (button) {
17384 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
17385 });
17386 var layoutEl = document.querySelector(".layout");
17387 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
17388 updateWizardProgress();
17389 updateStepNav(step);
17390 stepButtons.forEach(function(btn) {
17391 var t = Number(btn.getAttribute("data-step-target"));
17392 btn.classList.toggle("done", t < step);
17393 });
17394 updateSidebarSummary();
17395
17396 if (pushHistory !== false) {
17397 try {
17398 history.pushState({ wizardStep: step }, "", "#step" + step);
17399 } catch (e) {}
17400 }
17401
17402 window.scrollTo({ top: 0, behavior: "instant" });
17403 }
17404
17405 window.addEventListener("popstate", function (e) {
17406 if (e.state && e.state.wizardStep) {
17407 setStep(e.state.wizardStep, false);
17408 } else {
17409 var hashMatch = location.hash.match(/^#step([1-4])$/);
17410 if (hashMatch) setStep(Number(hashMatch[1]), false);
17411 }
17412 });
17413
17414 function inferTitleFromPath(value) {
17415 if (!value) return "project";
17416 var cleaned = value.replace(/[\/\\]+$/, "");
17417 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
17418 return parts.length ? parts[parts.length - 1] : value;
17419 }
17420
17421 function updateReportTitleFromPath() {
17422 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
17423 if (!reportTitleTouched) {
17424 reportTitleInput.value = inferred;
17425 }
17426 var title = reportTitleInput.value || inferred;
17427 if (liveReportTitle) liveReportTitle.textContent = title;
17428 if (reportTitlePreview) reportTitlePreview.textContent = title;
17429 document.title = "OxideSLOC | " + title;
17430
17431 var projectPath = (pathInput.value || "").trim();
17432 if (navProjectPill && navProjectTitle) {
17433 if (projectPath.length > 0) {
17434 navProjectTitle.textContent = inferred;
17435 navProjectPill.classList.add("visible");
17436 } else {
17437 navProjectTitle.textContent = "";
17438 navProjectPill.classList.remove("visible");
17439 }
17440 }
17441 }
17442
17443 function updateMixedPolicyUI() {
17444 var key = mixedLinePolicy.value || "code_only";
17445 var info = mixedPolicyInfo[key];
17446 document.getElementById("mixed-policy-description").textContent = info.description;
17447 document.getElementById("mixed-policy-example").textContent = info.example;
17448 }
17449
17450 function updatePythonDocstringUI() {
17451 var checked = !!pythonDocstrings.checked;
17452 document.getElementById("python-docstring-example").textContent = checked
17453 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
17454 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
17455 document.getElementById("python-docstring-live-help").textContent = checked
17456 ? "Enabled: docstrings contribute to comment-style totals."
17457 : "Disabled: docstrings are not counted as comment content.";
17458 }
17459
17460 function renderPresetChips(targetId, chips) {
17461 var target = document.getElementById(targetId);
17462 if (!target) return;
17463 target.innerHTML = (chips || []).map(function (chip) {
17464 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
17465 }).join('');
17466 }
17467
17468 function updatePresetDescriptions() {
17469 var scanInfo = scanPresetInfo[scanPreset.value];
17470 if (!scanInfo) return;
17471 document.getElementById("scan-preset-description").textContent = scanInfo.description;
17472 document.getElementById("scan-preset-example").textContent = scanInfo.example;
17473 document.getElementById("scan-preset-note").textContent = scanInfo.note;
17474 renderPresetChips("scan-preset-summary", scanInfo.chips);
17475 }
17476
17477 function applyScanPreset() {
17478 var info = scanPresetInfo[scanPreset.value];
17479 if (!info || !info.apply) return;
17480 mixedLinePolicy.value = info.apply.mixed;
17481 pythonDocstrings.checked = !!info.apply.docstrings;
17482 document.getElementById("generated_file_detection").value = info.apply.generated;
17483 document.getElementById("minified_file_detection").value = info.apply.minified;
17484 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
17485 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
17486 document.getElementById("binary_file_behavior").value = info.apply.binary;
17487 updateMixedPolicyUI();
17488 updatePythonDocstringUI();
17489 }
17490
17491 function updateReview() {
17492 var scanSummary = document.getElementById("review-scan-summary");
17493 var countSummary = document.getElementById("review-count-summary");
17494 var artifactSummary = document.getElementById("review-artifact-summary");
17495 var outputSummary = document.getElementById("review-output-summary");
17496 var previewSummary = document.getElementById("review-preview-summary");
17497 var readinessSummary = document.getElementById("review-readiness-summary");
17498 var includeText = document.getElementById("include_globs").value.trim();
17499 var excludeText = document.getElementById("exclude_globs").value.trim();
17500 var sidePathPreview = document.getElementById("side-path-preview");
17501 var sideOutputPreview = document.getElementById("side-output-preview");
17502 var sideTitlePreview = document.getElementById("side-title-preview");
17503
17504 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
17505 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
17506 if (sideTitlePreview) {
17507 var rt = document.getElementById("report_title");
17508 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
17509 }
17510
17511 scanSummary.innerHTML = ""
17512 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
17513 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
17514 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
17515
17516 countSummary.innerHTML = ""
17517 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
17518 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
17519 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
17520 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
17521 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
17522 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
17523 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
17524 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
17525
17526 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
17527
17528 outputSummary.innerHTML = ""
17529 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
17530 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
17531
17532 if (previewSummary) {
17533 if (GIT_MODE) {
17534 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode — the repository will be cloned and fully analyzed during the scan run.</li>';
17535 } else {
17536 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
17537 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
17538 var statMap = {};
17539 statButtons.forEach(function (button) {
17540 var valueNode = button.querySelector('.scope-stat-value');
17541 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
17542 });
17543 previewSummary.innerHTML = ''
17544 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
17545 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
17546 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
17547 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
17548 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
17549 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
17550
17551 if (readinessSummary) {
17552 readinessSummary.innerHTML = ''
17553 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
17554 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
17555 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
17556 }
17557 } // end else (non-GIT_MODE)
17558 }
17559 }
17560
17561 function escapeHtml(value) {
17562 return String(value)
17563 .replace(/&/g, "&")
17564 .replace(/</g, "<")
17565 .replace(/>/g, ">")
17566 .replace(/"/g, """)
17567 .replace(/'/g, "'");
17568 }
17569
17570 function isPythonVisible() {
17571 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
17572 }
17573
17574 function syncPythonVisibility() {
17575 var html = previewPanel.textContent || "";
17576 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
17577 pythonWraps.forEach(function (node) {
17578 node.classList.toggle("hidden", !hasPython);
17579 });
17580 }
17581
17582 function attachPreviewInteractions() {
17583 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
17584 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
17585 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
17586 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
17587 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
17588 var searchInput = previewPanel.querySelector("#explorer-search");
17589 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
17590 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
17591 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
17592 var activeFilter = "all";
17593 var activeLanguage = "";
17594 var searchTerm = "";
17595 var currentSortKey = null;
17596 var currentSortOrder = "asc";
17597 var childRows = {};
17598
17599 rows.forEach(function (row) {
17600 var parentId = row.getAttribute("data-parent-id") || "";
17601 var rowId = row.getAttribute("data-row-id") || "";
17602 if (!childRows[parentId]) childRows[parentId] = [];
17603 childRows[parentId].push(rowId);
17604 });
17605
17606 function rowById(id) {
17607 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
17608 }
17609
17610 function hasCollapsedAncestor(row) {
17611 var parentId = row.getAttribute("data-parent-id");
17612 while (parentId) {
17613 var parent = rowById(parentId);
17614 if (!parent) break;
17615 if (parent.getAttribute("data-expanded") === "false") return true;
17616 parentId = parent.getAttribute("data-parent-id");
17617 }
17618 return false;
17619 }
17620
17621 function updateToggleGlyph(row) {
17622 var toggle = row.querySelector(".tree-toggle");
17623 if (!toggle) return;
17624 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
17625 }
17626
17627 function rowSortValue(row, key) {
17628 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
17629 }
17630
17631 function updateSortButtons() {
17632 sortButtons.forEach(function (button) {
17633 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
17634 var indicator = button.querySelector(".tree-sort-indicator");
17635 button.classList.toggle("active", isActive);
17636 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
17637 if (indicator) {
17638 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
17639 }
17640 });
17641 }
17642
17643 function sortSiblingRows() {
17644 if (!treeContainer) {
17645 updateSortButtons();
17646 return;
17647 }
17648
17649 var rowMap = {};
17650 var childrenMap = {};
17651 rows.forEach(function (row) {
17652 var rowId = row.getAttribute("data-row-id");
17653 var parentId = row.getAttribute("data-parent-id") || "";
17654 rowMap[rowId] = row;
17655 if (!childrenMap[parentId]) childrenMap[parentId] = [];
17656 childrenMap[parentId].push(rowId);
17657 });
17658
17659 Object.keys(childrenMap).forEach(function (parentId) {
17660 if (!parentId) return;
17661 childrenMap[parentId].sort(function (a, b) {
17662 var rowA = rowMap[a];
17663 var rowB = rowMap[b];
17664 if (!currentSortKey) {
17665 return Number(a) - Number(b);
17666 }
17667 var valueA = rowSortValue(rowA, currentSortKey);
17668 var valueB = rowSortValue(rowB, currentSortKey);
17669 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
17670 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
17671 var fallbackA = rowSortValue(rowA, "name");
17672 var fallbackB = rowSortValue(rowB, "name");
17673 if (fallbackA < fallbackB) return -1;
17674 if (fallbackA > fallbackB) return 1;
17675 return Number(a) - Number(b);
17676 });
17677 });
17678
17679 var orderedIds = [];
17680 function pushChildren(parentId) {
17681 (childrenMap[parentId] || []).forEach(function (childId) {
17682 orderedIds.push(childId);
17683 pushChildren(childId);
17684 });
17685 }
17686
17687 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
17688 orderedIds.push(topId);
17689 pushChildren(topId);
17690 });
17691
17692 orderedIds.forEach(function (id) {
17693 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
17694 });
17695 updateSortButtons();
17696 }
17697
17698 function updateLanguageButtons() {
17699 languageButtons.forEach(function (button) {
17700 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
17701 var isActive = languageValue === activeLanguage;
17702 button.classList.toggle("active", isActive);
17703 });
17704 }
17705
17706 function rowSelfMatches(row) {
17707 var kind = row.getAttribute("data-kind");
17708 var status = row.getAttribute("data-status");
17709 var language = (row.getAttribute("data-language") || "").toLowerCase();
17710 var name = row.getAttribute("data-name-lower") || "";
17711 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
17712 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
17713 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
17714 var passesLanguage = !activeLanguage || language === activeLanguage;
17715 return passesFilter && passesSearch && passesLanguage;
17716 }
17717
17718 function hasMatchingDescendant(rowId) {
17719 return (childRows[rowId] || []).some(function (childId) {
17720 var childRow = rowById(childId);
17721 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
17722 });
17723 }
17724
17725 function rowMatches(row) {
17726 if (rowSelfMatches(row)) return true;
17727 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
17728 }
17729
17730 function resetViewState() {
17731 activeFilter = "all";
17732 activeLanguage = "";
17733 searchTerm = "";
17734 currentSortKey = null;
17735 currentSortOrder = "asc";
17736 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17737 if (searchInput) searchInput.value = "";
17738 if (filterSelect) filterSelect.value = "all";
17739 updateLanguageButtons();
17740 }
17741
17742 function applyVisibility() {
17743 rows.forEach(function (row) {
17744 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
17745 row.classList.toggle("hidden-by-filter", !visible);
17746 row.style.display = visible ? "grid" : "none";
17747 });
17748 buttons.forEach(function (button) {
17749 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
17750 });
17751 if (filterSelect) filterSelect.value = activeFilter;
17752 }
17753
17754 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
17755 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
17756 var originalStats = {};
17757 buttons.forEach(function (btn) {
17758 var f = btn.getAttribute('data-filter');
17759 var v = btn.querySelector('.scope-stat-value');
17760 if (f && v) originalStats[f] = v.textContent;
17761 });
17762
17763 function applySubmoduleStats(statsJson) {
17764 try {
17765 var s = JSON.parse(statsJson);
17766 buttons.forEach(function (btn) {
17767 var f = btn.getAttribute('data-filter');
17768 var v = btn.querySelector('.scope-stat-value');
17769 if (!v) return;
17770 if (f === 'dir') v.textContent = s.dirs;
17771 else if (f === 'file') v.textContent = s.files;
17772 else if (f === 'supported') v.textContent = s.supported;
17773 else if (f === 'skipped') v.textContent = s.skipped;
17774 else if (f === 'unsupported') v.textContent = s.unsupported;
17775 });
17776 } catch (e) {}
17777 }
17778
17779 function restoreBaseRepoStats() {
17780 buttons.forEach(function (btn) {
17781 var f = btn.getAttribute('data-filter');
17782 var v = btn.querySelector('.scope-stat-value');
17783 if (v && originalStats[f]) v.textContent = originalStats[f];
17784 });
17785 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17786 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
17787 }
17788
17789 submoduleChips.forEach(function (chip) {
17790 chip.addEventListener('click', function () {
17791 var statsJson = chip.getAttribute('data-sub-stats');
17792 if (!statsJson) return;
17793 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17794 chip.classList.add('active');
17795 applySubmoduleStats(statsJson);
17796 if (baseRepoBtn) baseRepoBtn.style.display = '';
17797 });
17798 });
17799
17800 if (baseRepoBtn) {
17801 baseRepoBtn.addEventListener('click', function () {
17802 restoreBaseRepoStats();
17803 resetViewState();
17804 sortSiblingRows();
17805 applyVisibility();
17806 });
17807 }
17808
17809 buttons.forEach(function (button) {
17810 button.addEventListener("click", function () {
17811 var filterValue = button.getAttribute("data-filter") || "all";
17812 if (filterValue === "reset-view") {
17813 restoreBaseRepoStats();
17814 resetViewState();
17815 sortSiblingRows();
17816 applyVisibility();
17817 return;
17818 }
17819 activeFilter = filterValue;
17820 applyVisibility();
17821 });
17822 });
17823
17824 rows.forEach(function (row) {
17825 updateToggleGlyph(row);
17826 var toggle = row.querySelector(".tree-toggle");
17827 if (toggle) {
17828 toggle.addEventListener("click", function () {
17829 var expanded = row.getAttribute("data-expanded") !== "false";
17830 row.setAttribute("data-expanded", expanded ? "false" : "true");
17831 updateToggleGlyph(row);
17832 applyVisibility();
17833 });
17834 }
17835 });
17836
17837 actionButtons.forEach(function (button) {
17838 button.addEventListener("click", function () {
17839 var action = button.getAttribute("data-explorer-action");
17840 if (action === "expand-all") {
17841 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17842 } else if (action === "collapse-all") {
17843 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
17844 } else if (action === "clear-filters") {
17845 resetViewState();
17846 }
17847 sortSiblingRows();
17848 applyVisibility();
17849 });
17850 });
17851
17852 if (filterSelect) {
17853 filterSelect.addEventListener("change", function () {
17854 activeFilter = filterSelect.value || "all";
17855 applyVisibility();
17856 });
17857 }
17858
17859 languageButtons.forEach(function (button) {
17860 button.addEventListener("click", function () {
17861 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
17862 updateLanguageButtons();
17863 applyVisibility();
17864 });
17865 });
17866
17867 sortButtons.forEach(function (button) {
17868 button.addEventListener("click", function () {
17869 var sortKey = button.getAttribute("data-sort-key");
17870 if (currentSortKey === sortKey) {
17871 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
17872 } else {
17873 currentSortKey = sortKey;
17874 currentSortOrder = "asc";
17875 }
17876 sortSiblingRows();
17877 applyVisibility();
17878 });
17879 });
17880
17881 if (searchInput) {
17882 searchInput.addEventListener("input", function () {
17883 searchTerm = searchInput.value.trim().toLowerCase();
17884 applyVisibility();
17885 });
17886 }
17887
17888 updateLanguageButtons();
17889 sortSiblingRows();
17890 applyVisibility();
17891 }
17892
17893 function loadPreview() {
17894 if (!previewPanel || !pathInput) return;
17895 if (GIT_MODE) {
17896 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>';
17897 return;
17898 }
17899 var path = pathInput.value.trim();
17900 var zeroWarn = document.getElementById('zero-files-warning');
17901 if (!path) {
17902 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
17903 if (zeroWarn) zeroWarn.style.display = 'none';
17904 return;
17905 }
17906 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
17907 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
17908 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
17909 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
17910 var myGen = ++_previewGen;
17911 var _prevMsgs = [
17912 'Scanning directory structure…',
17913 'Detecting file types…',
17914 'Applying include / exclude filters…',
17915 'Estimating file counts…',
17916 'Building scope preview…',
17917 'Almost there…'
17918 ];
17919 var _prevMsgIdx = 0;
17920 var _prevStart = Date.now();
17921 previewPanel.innerHTML =
17922 '<div class="preview-loading">' +
17923 '<div class="preview-spinner"></div>' +
17924 '<div class="preview-loading-text">' +
17925 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
17926 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
17927 '</div></div>';
17928 var _sizeTextEl = document.getElementById('project-size-text');
17929 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
17930 window._previewInterval = setInterval(function() {
17931 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
17932 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
17933 var ml = document.getElementById('plm');
17934 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
17935 }, 1500);
17936 window._previewElapsedTimer = setInterval(function() {
17937 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
17938 var el = document.getElementById('ple');
17939 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
17940 }, 1000);
17941 var previewUrl = "/preview?path=" + encodeURIComponent(path)
17942 + "&include_globs=" + encodeURIComponent(includeValue)
17943 + "&exclude_globs=" + encodeURIComponent(excludeValue);
17944 fetch(previewUrl)
17945 .then(function (response) { return response.text(); })
17946 .then(function (html) {
17947 if (myGen !== _previewGen) return;
17948 clearInterval(window._previewInterval); window._previewInterval = null;
17949 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17950 previewPanel.innerHTML = html;
17951 attachPreviewInteractions();
17952 syncPythonVisibility();
17953 updateReview();
17954 setTimeout(collapseLanguagePills, 50);
17955 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
17956 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
17957 var sizeText = document.getElementById('project-size-text');
17958 var sizeBtn = document.getElementById('project-size-btn');
17959 // In server mode with upload sizes available, keep the compressed/original pair.
17960 if (SERVER_MODE && window._lastUploadSizes) {
17961 var us = window._lastUploadSizes;
17962 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
17963 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
17964 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
17965 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
17966 } else if (sizeText && projectSize) {
17967 sizeText.textContent = 'Project size: ' + projectSize;
17968 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
17969 } else if (sizeText) {
17970 sizeText.textContent = 'Project size: —';
17971 }
17972 if (zeroWarn) {
17973 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
17974 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
17975 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
17976 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
17977 if (supportedCount === 0 && fileCount > 0) {
17978 zeroWarn.textContent = '⚠ Warning: No supported source files detected—this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
17979 zeroWarn.style.display = '';
17980 } else {
17981 zeroWarn.style.display = 'none';
17982 }
17983 }
17984 })
17985 .catch(function (err) {
17986 if (myGen !== _previewGen) return;
17987 clearInterval(window._previewInterval); window._previewInterval = null;
17988 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17989 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
17990 });
17991 }
17992
17993 function pickDirectory(targetInput, kind) {
17994 if (!targetInput) {
17995 showBannerToast("Directory picker: input element not found.", true);
17996 return;
17997 }
17998 if (SERVER_MODE) {
17999 if (kind === 'output') {
18000 showBannerToast(
18001 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
18002 false,
18003 { top: true, icon: '📁' }
18004 );
18005 return;
18006 }
18007 var inputEl = kind === 'coverage'
18008 ? document.getElementById('cov-upload-input')
18009 : document.getElementById('dir-upload-input');
18010 if (!inputEl) return;
18011 inputEl.onchange = function () {
18012 var files = inputEl.files;
18013 if (!files || files.length === 0) return;
18014 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
18015 if (browseBtn) browseBtn.disabled = true;
18016
18017 function fileToBase64(file) {
18018 return new Promise(function (resolve, reject) {
18019 var reader = new FileReader();
18020 reader.onload = function () {
18021 var b64 = reader.result.split(',')[1];
18022 resolve(b64);
18023 };
18024 reader.onerror = reject;
18025 reader.readAsDataURL(file);
18026 });
18027 }
18028
18029 if (kind === 'coverage') {
18030 var f = files[0];
18031 if (previewPanel && targetInput === pathInput)
18032 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
18033 fileToBase64(f).then(function (b64) {
18034 return fetch('/api/upload-file', {
18035 method: 'POST',
18036 headers: { 'Content-Type': 'application/json' },
18037 body: JSON.stringify({ filename: f.name, content: b64 })
18038 }).then(function (r) { return r.json(); });
18039 })
18040 .then(function (d) {
18041 if (d && d.tmp_path) {
18042 if (coverageInput) coverageInput.value = d.tmp_path;
18043 setCovStatus('idle');
18044 } else if (d && d.error) { showBannerToast(d.error, true); }
18045 })
18046 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
18047 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
18048 } else {
18049 // ── Filter to source-code files only ─────────────────────────
18050 // Binary, generated, and dependency files (node_modules, .git,
18051 // build artifacts) are skipped so they are never uploaded.
18052 var CODE_EXTS = new Set([
18053 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18054 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18055 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18056 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18057 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18058 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
18059 'tf','hcl','proto','thrift','avsc','graphql','gql'
18060 ]);
18061 var codeFiles = [];
18062 for (var i = 0; i < files.length; i++) {
18063 var f = files[i];
18064 var name = f.name;
18065 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
18066 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
18067 codeFiles.push(f); continue;
18068 }
18069 var dot = name.lastIndexOf('.');
18070 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
18071 }
18072 // Collect specific .git metadata files for server-side git detection.
18073 // These have no source extension so they are excluded by the loop above,
18074 // but the server needs them to read branch/commit/author without running git.
18075 var gitMetaFiles = [];
18076 for (var i = 0; i < files.length; i++) {
18077 var f = files[i];
18078 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
18079 var gitIdx = rp.indexOf('/.git/');
18080 if (gitIdx < 0) continue;
18081 var gitRel = rp.slice(gitIdx + 1);
18082 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
18083 gitRel === '.git/logs/HEAD' ||
18084 gitRel.startsWith('.git/refs/heads/') ||
18085 gitRel.startsWith('.git/refs/tags/')) {
18086 gitMetaFiles.push(f);
18087 }
18088 }
18089 var uploadFiles = codeFiles.concat(gitMetaFiles);
18090 var total = files.length;
18091 var kept = codeFiles.length;
18092 if (kept === 0) {
18093 if (previewPanel && targetInput === pathInput)
18094 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
18095 if (browseBtn) browseBtn.disabled = false;
18096 inputEl.value = '';
18097 return;
18098 }
18099
18100 // ── Helper: apply upload result to UI ────────────────────────
18101 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
18102 function applyUploadResult(tmpPath, sizes) {
18103 targetInput.value = tmpPath;
18104 scrollInputToEnd(targetInput);
18105 if (sizes && SERVER_MODE) {
18106 window._lastUploadSizes = sizes;
18107 // Immediately show both sizes before preview loads.
18108 var sizeText = document.getElementById('project-size-text');
18109 var sizeBtn = document.getElementById('project-size-btn');
18110 if (sizeText) {
18111 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18112 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18113 }
18114 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18115 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18116 }
18117 if (targetInput === pathInput) {
18118 updateReportTitleFromPath();
18119 autoSetOutputDir(tmpPath);
18120 fetchProjectHistory(tmpPath);
18121 loadPreview();
18122 suggestCoverageFile(tmpPath);
18123 }
18124 updateReview();
18125 if (browseBtn) browseBtn.disabled = false;
18126 inputEl.value = '';
18127 }
18128
18129 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
18130 if (typeof CompressionStream !== 'undefined') {
18131 if (previewPanel && targetInput === pathInput)
18132 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18133
18134 // Build a minimal POSIX ustar tar header for a single file entry.
18135 function buildUstarHeader(filePath, fileSize) {
18136 var BLOCK = 512;
18137 var hdr = new Uint8Array(BLOCK);
18138 var enc = new TextEncoder();
18139 function wStr(off, len, s) {
18140 var b = enc.encode(s);
18141 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
18142 }
18143 function wOct(off, len, val) {
18144 var s = val.toString(8);
18145 while (s.length < len - 1) s = '0' + s;
18146 wStr(off, len, s + '\0');
18147 }
18148 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
18149 var name = filePath, prefix = '';
18150 if (filePath.length > 99) {
18151 var split = filePath.lastIndexOf('/', 154);
18152 if (split > 0 && filePath.length - split - 1 <= 99) {
18153 prefix = filePath.substring(0, split);
18154 name = filePath.substring(split + 1);
18155 } else { name = filePath.substring(0, 99); }
18156 }
18157 wStr(0, 100, name); // name
18158 wOct(100, 8, 0o000644); // mode
18159 wOct(108, 8, 0); // uid
18160 wOct(116, 8, 0); // gid
18161 wOct(124, 12, fileSize); // size
18162 wOct(136, 12, 0); // mtime (epoch)
18163 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
18164 hdr[156] = 48; // type flag '0' = regular file
18165 wStr(157, 100, ''); // linkname
18166 wStr(257, 6, 'ustar'); // magic
18167 wStr(263, 2, '00'); // version
18168 wStr(265, 32, ''); // uname
18169 wStr(297, 32, ''); // gname
18170 wOct(329, 8, 0); // devmajor
18171 wOct(337, 8, 0); // devminor
18172 wStr(345, 155, prefix); // prefix
18173 // Compute checksum (sum of all bytes, placeholder = 32).
18174 var chk = 0;
18175 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18176 var cs = chk.toString(8);
18177 while (cs.length < 6) cs = '0' + cs;
18178 wStr(148, 8, cs + '\0 ');
18179 return hdr;
18180 }
18181
18182 // Build tar.gz one file at a time, piping through CompressionStream.
18183 // RAM usage = compressed output buffer + one file at a time.
18184 (async function () {
18185 try {
18186 var BLOCK = 512;
18187 var cs = new CompressionStream('gzip');
18188 var writer = cs.writable.getWriter();
18189 var chunks = [];
18190 var reader = cs.readable.getReader();
18191 var collecting = (async function () {
18192 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
18193 })();
18194
18195 for (var i = 0; i < uploadFiles.length; i++) {
18196 var file = uploadFiles[i];
18197 var path = file.webkitRelativePath || file.name;
18198 var buf = await file.arrayBuffer();
18199 var data = new Uint8Array(buf);
18200 // Header block
18201 await writer.write(buildUstarHeader(path, data.length));
18202 // Data padded to 512-byte boundary
18203 if (data.length > 0) {
18204 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
18205 var block = new Uint8Array(padded);
18206 block.set(data);
18207 await writer.write(block);
18208 }
18209 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
18210 if (previewPanel && targetInput === pathInput)
18211 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18212 }
18213 }
18214 // End-of-archive: two 512-byte zero blocks
18215 await writer.write(new Uint8Array(BLOCK * 2));
18216 await writer.close();
18217 await collecting;
18218
18219 var blob = new Blob(chunks, { type: 'application/gzip' });
18220 var sizeMB = (blob.size / 1048576).toFixed(1);
18221 if (previewPanel && targetInput === pathInput)
18222 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
18223
18224 var resp = await fetch('/api/upload-tarball', {
18225 method: 'POST',
18226 headers: { 'Content-Type': 'application/gzip' },
18227 body: blob
18228 });
18229 var d = await resp.json();
18230 if (d && d.tmp_path) {
18231 applyUploadResult(d.tmp_path, {
18232 compressed_bytes: d.compressed_bytes || 0,
18233 original_bytes: d.original_bytes || 0
18234 });
18235 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18236 } catch (e) {
18237 showBannerToast('Upload failed: ' + String(e), true);
18238 if (browseBtn) browseBtn.disabled = false;
18239 inputEl.value = '';
18240 }
18241 })();
18242
18243 } else {
18244 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
18245 // Used only on browsers that lack CompressionStream (pre-2023).
18246 var BATCH = 200;
18247 var batches = [];
18248 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
18249 var totalBatches = batches.length;
18250 if (previewPanel && targetInput === pathInput)
18251 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
18252
18253 function sendBatch(idx, currentUploadId, lastTmpPath) {
18254 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
18255 if (previewPanel && targetInput === pathInput && totalBatches > 1)
18256 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
18257 Promise.all(batches[idx].map(function (file) {
18258 return fileToBase64(file).then(function (b64) {
18259 return { path: file.webkitRelativePath || file.name, content: b64 };
18260 });
18261 })).then(function (fileList) {
18262 var body = { files: fileList };
18263 if (currentUploadId) body.upload_id = currentUploadId;
18264 return fetch('/api/upload-directory', {
18265 method: 'POST', headers: { 'Content-Type': 'application/json' },
18266 body: JSON.stringify(body)
18267 }).then(function (r) { return r.json(); });
18268 }).then(function (d) {
18269 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
18270 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18271 }).catch(function (e) {
18272 showBannerToast('Upload failed: ' + String(e), true);
18273 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
18274 });
18275 }
18276 sendBatch(0, null, '');
18277 }
18278 }
18279 };
18280 inputEl.click();
18281 return;
18282 }
18283
18284 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
18285 if (browseButton) browseButton.disabled = true;
18286
18287 if (previewPanel && targetInput === pathInput) {
18288 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
18289 }
18290
18291 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
18292 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
18293 .then(function (data) {
18294 if (data && data.selected_path) {
18295 targetInput.value = data.selected_path;
18296 scrollInputToEnd(targetInput);
18297
18298 if (targetInput === pathInput) {
18299 updateReportTitleFromPath();
18300 autoSetOutputDir(data.selected_path);
18301 fetchProjectHistory(data.selected_path);
18302 loadPreview();
18303 suggestCoverageFile(data.selected_path);
18304 }
18305
18306 updateReview();
18307 } else if (targetInput === pathInput) {
18308 loadPreview();
18309 }
18310 })
18311 .catch(function () {
18312 window.alert("Directory picker request failed.");
18313 if (previewPanel && targetInput === pathInput) {
18314 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
18315 }
18316 })
18317 .finally(function () {
18318 if (browseButton) browseButton.disabled = false;
18319 });
18320 }
18321
18322 if (themeToggle) {
18323 themeToggle.addEventListener("click", function () {
18324 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
18325 applyTheme(nextTheme);
18326 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
18327 });
18328 }
18329
18330 stepButtons.forEach(function (button) {
18331 button.addEventListener("click", function () {
18332 setStep(Number(button.getAttribute("data-step-target")));
18333 });
18334 });
18335
18336 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
18337 button.addEventListener("click", function () {
18338 setStep(Number(button.getAttribute("data-step-target")) || 1);
18339 });
18340 });
18341
18342 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
18343 button.addEventListener("click", function () {
18344 updateReview();
18345 setStep(Number(button.getAttribute("data-next")));
18346 });
18347 });
18348
18349 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
18350 button.addEventListener("click", function () {
18351 setStep(Number(button.getAttribute("data-prev")));
18352 });
18353 });
18354
18355 document.addEventListener("keydown", function (e) {
18356 var tag = (document.activeElement || {}).tagName || "";
18357 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
18358 if (e.altKey || e.ctrlKey || e.metaKey) return;
18359 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
18360 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
18361 });
18362
18363 if (useSamplePath) {
18364 useSamplePath.addEventListener("click", function () {
18365 pathInput.value = "tests/fixtures/basic";
18366 updateReportTitleFromPath();
18367 autoSetOutputDir("tests/fixtures/basic");
18368 loadPreview();
18369 suggestCoverageFile("tests/fixtures/basic");
18370 });
18371 }
18372
18373 if (useDefaultOutput) {
18374 useDefaultOutput.addEventListener("click", function () {
18375 delete outputDirInput.dataset.userEdited;
18376 autoSetOutputDir(pathInput ? pathInput.value : "");
18377 updateReview();
18378 });
18379 }
18380
18381 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
18382 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
18383
18384 // ── Drag-and-drop directory upload (server mode only) ─────────────────
18385 // Dropping a folder onto the path field bypasses Chrome's
18386 // "Upload X files to this site?" confirmation dialog.
18387 async function readDirRecursively(dirEntry, basePath) {
18388 var reader = dirEntry.createReader();
18389 var all = [];
18390 for (;;) {
18391 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
18392 if (!batch.length) break;
18393 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
18394 }
18395 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
18396 var out = [];
18397 for (var i = 0; i < all.length; i++) {
18398 var sub = all[i];
18399 if (sub.isFile) {
18400 var f = await new Promise(function(res) { sub.file(res); });
18401 out.push({ file: f, path: basePath + '/' + sub.name });
18402 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
18403 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
18404 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
18405 }
18406 }
18407 return out;
18408 }
18409
18410 function setupPathDropZone() {
18411 if (!SERVER_MODE || !pathInput) return;
18412 var CODE_EXTS = new Set([
18413 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18414 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18415 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18416 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18417 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18418 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
18419 ]);
18420 pathInput.addEventListener('dragover', function(e) {
18421 e.preventDefault();
18422 pathInput.classList.add('drag-over');
18423 });
18424 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
18425 pathInput.addEventListener('drop', function(e) {
18426 e.preventDefault();
18427 pathInput.classList.remove('drag-over');
18428 var items = e.dataTransfer.items;
18429 if (!items || !items.length) return;
18430 var dirEntry = null;
18431 for (var i = 0; i < items.length; i++) {
18432 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
18433 if (entry && entry.isDirectory) { dirEntry = entry; break; }
18434 }
18435 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
18436 var btn = browsePath;
18437 if (btn) btn.disabled = true;
18438 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
18439
18440 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
18441 var total = allEntries.length;
18442 var codeEntries = allEntries.filter(function(e) {
18443 var n = e.file.name;
18444 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
18445 var dot = n.lastIndexOf('.');
18446 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
18447 });
18448 var kept = codeEntries.length;
18449 if (kept === 0) {
18450 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
18451 if (btn) btn.disabled = false; return;
18452 }
18453
18454 function finish(tmpPath, sizes) {
18455 pathInput.value = tmpPath;
18456 scrollInputToEnd(pathInput);
18457 if (sizes) {
18458 window._lastUploadSizes = sizes;
18459 var sizeText = document.getElementById('project-size-text');
18460 var sizeBtn = document.getElementById('project-size-btn');
18461 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18462 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18463 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18464 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18465 }
18466 updateReportTitleFromPath();
18467 autoSetOutputDir(tmpPath);
18468 fetchProjectHistory(tmpPath);
18469 loadPreview();
18470 suggestCoverageFile(tmpPath);
18471 updateReview();
18472 if (btn) btn.disabled = false;
18473 }
18474
18475 if (typeof CompressionStream === 'undefined') {
18476 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
18477 if (btn) btn.disabled = false; return;
18478 }
18479
18480 try {
18481 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18482 var BLOCK = 512;
18483 var cs = new CompressionStream('gzip');
18484 var wtr = cs.writable.getWriter();
18485 var chunks = [];
18486 var rdr = cs.readable.getReader();
18487 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
18488
18489 function buildHdr(fp, sz) {
18490 var hdr = new Uint8Array(BLOCK);
18491 var enc = new TextEncoder();
18492 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]; }
18493 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
18494 var nm = fp, pfx = '';
18495 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); } }
18496 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
18497 for (var i = 148; i < 156; i++) hdr[i] = 32;
18498 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);
18499 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18500 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
18501 return hdr;
18502 }
18503
18504 for (var i = 0; i < codeEntries.length; i++) {
18505 var ce = codeEntries[i];
18506 var buf = await ce.file.arrayBuffer();
18507 var data = new Uint8Array(buf);
18508 await wtr.write(buildHdr(ce.path, data.length));
18509 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
18510 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
18511 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18512 }
18513 await wtr.write(new Uint8Array(BLOCK * 2));
18514 await wtr.close();
18515 await collecting;
18516
18517 var blob = new Blob(chunks, { type: 'application/gzip' });
18518 var sizeMB = (blob.size / 1048576).toFixed(1);
18519 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
18520 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
18521 var d = await resp.json();
18522 if (d && d.tmp_path) {
18523 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
18524 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
18525 } catch (err) {
18526 showBannerToast('Upload failed: ' + String(err), true);
18527 if (btn) btn.disabled = false;
18528 }
18529 }).catch(function(err) {
18530 showBannerToast('Could not read folder: ' + String(err), true);
18531 if (btn) btn.disabled = false;
18532 });
18533 });
18534 }
18535 setupPathDropZone();
18536 if (browseCoverage) {
18537 browseCoverage.addEventListener("click", function () {
18538 pickDirectory(coverageInput || pathInput, "coverage");
18539 });
18540 }
18541
18542 function setCovStatus(state, opts) {
18543 if (!covScanStatus) return;
18544 opts = opts || {};
18545 covScanStatus.className = "cov-scan-status cov-scan-" + state;
18546 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
18547 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>';
18548 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>';
18549 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>';
18550 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>';
18551 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
18552 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
18553 if (state === "scanning") {
18554 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
18555 } else if (state === "found") {
18556 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18557 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
18558 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
18559 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
18560 } else if (state === "hint") {
18561 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18562 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
18563 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</div>';
18564 } else if (state === "none") {
18565 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
18566 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
18567 }
18568 html += '</div></div>';
18569 covScanStatus.innerHTML = html;
18570 if (state === "found") {
18571 var useBtn = covScanStatus.querySelector(".cov-scan-use");
18572 if (useBtn) useBtn.addEventListener("click", function () {
18573 if (coverageInput) coverageInput.value = "";
18574 covAutoFilled = false;
18575 setCovStatus("idle");
18576 });
18577 }
18578 }
18579
18580 function suggestCoverageFile(projectPath) {
18581 if (!coverageInput || !covScanStatus) return;
18582 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18583 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
18584 clearTimeout(coverageSuggestTimer);
18585 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
18586 setCovStatus("scanning");
18587 coverageSuggestTimer = setTimeout(function () {
18588 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
18589 .then(function (r) { return r.json(); })
18590 .then(function (d) {
18591 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18592 if (!d) { setCovStatus("none"); return; }
18593 if (d.found) {
18594 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
18595 setCovStatus("found", { found: d.found, tool: d.tool });
18596 } else if (d.tool && d.hint) {
18597 setCovStatus("hint", { tool: d.tool, hint: d.hint });
18598 } else {
18599 setCovStatus("none");
18600 }
18601 })
18602 .catch(function () { setCovStatus("idle"); });
18603 }, 600);
18604 }
18605
18606 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
18607
18608 if (coverageInput) coverageInput.addEventListener("input", function () {
18609 covAutoFilled = false;
18610 if (!this.value.trim()) setCovStatus("idle");
18611 });
18612
18613 // ── Language pill overflow: collapse to "+N more" chip ─────────────
18614 function collapseLanguagePills() {
18615 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
18616 rows.forEach(function(row) {
18617 // Remove any previous overflow chip
18618 var prev = row.querySelector('.lang-overflow-chip');
18619 if (prev) prev.remove();
18620 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
18621 pills.forEach(function(p) { p.style.display = ''; });
18622 if (!pills.length) return;
18623
18624 // Measure after restoring all pills
18625 var containerRight = row.getBoundingClientRect().right;
18626 var hidden = [];
18627 for (var i = pills.length - 1; i >= 1; i--) {
18628 var rect = pills[i].getBoundingClientRect();
18629 if (rect.right > containerRight + 2) {
18630 hidden.unshift(pills[i]);
18631 pills[i].style.display = 'none';
18632 } else {
18633 break;
18634 }
18635 }
18636
18637 if (hidden.length) {
18638 var chip = document.createElement('button');
18639 chip.type = 'button';
18640 chip.className = 'language-pill lang-overflow-chip';
18641 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
18642 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
18643 row.appendChild(chip);
18644 }
18645 });
18646 }
18647
18648 // Run after preview loads (preview panel populates language pills)
18649 var _origLoadPreviewCb = window.__previewLoaded;
18650 document.addEventListener('previewLoaded', collapseLanguagePills);
18651 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
18652 setTimeout(collapseLanguagePills, 400);
18653
18654 // ── Project history & output dir auto-set ──────────────────────────
18655 var wsOutputRoot = document.getElementById("ws-output-root");
18656 var wsScanCount = document.getElementById("ws-scan-count");
18657 var wsLastScan = document.getElementById("ws-last-scan");
18658 var historyBadge = document.getElementById("path-history-badge");
18659 var historyTimer = null;
18660
18661 var wsOutputLink = document.getElementById("ws-output-link");
18662 function syncStripOutputRoot() {
18663 var val = outputDirInput ? outputDirInput.value : "";
18664 var display = val || "project/sloc";
18665 if (wsOutputRoot) wsOutputRoot.textContent = display;
18666 if (wsOutputLink) wsOutputLink.dataset.folder = val;
18667 }
18668
18669 function scrollInputToEnd(input) {
18670 if (!input) return;
18671 // Defer so the DOM has the new value before we measure scroll width.
18672 requestAnimationFrame(function () {
18673 input.scrollLeft = input.scrollWidth;
18674 input.selectionStart = input.selectionEnd = input.value.length;
18675 });
18676 }
18677
18678 function autoSetOutputDir(projectPath) {
18679 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
18680 if (GIT_MODE && GIT_OUTPUT_DIR) {
18681 outputDirInput.value = GIT_OUTPUT_DIR;
18682 scrollInputToEnd(outputDirInput);
18683 syncStripOutputRoot();
18684 updateReview();
18685 return;
18686 }
18687 if (!projectPath || !projectPath.trim()) return;
18688 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
18689 outputDirInput.value = cleaned + "/sloc";
18690 scrollInputToEnd(outputDirInput);
18691 syncStripOutputRoot();
18692 updateReview();
18693 }
18694
18695 var wsBranch = document.getElementById("ws-branch");
18696
18697 function fetchProjectHistory(projectPath) {
18698 if (!projectPath || !projectPath.trim()) {
18699 if (wsScanCount) wsScanCount.textContent = "—";
18700 if (wsLastScan) wsLastScan.textContent = "—";
18701 if (wsBranch) wsBranch.textContent = "—";
18702 if (historyBadge) historyBadge.style.display = "none";
18703 return;
18704 }
18705 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
18706 .then(function (r) { return r.ok ? r.json() : null; })
18707 .then(function (data) {
18708 if (!data) return;
18709 var countStr = data.scan_count > 0
18710 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
18711 : "never";
18712 var tsStr = data.last_scan_timestamp
18713 ? data.last_scan_timestamp.replace(" UTC","")
18714 : "—";
18715 if (wsScanCount) wsScanCount.textContent = countStr;
18716 if (wsLastScan) wsLastScan.textContent = tsStr;
18717 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
18718 if (data.scan_count > 0) {
18719 if (historyBadge) {
18720 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
18721 historyBadge.textContent = data.scan_count + " previous scan" +
18722 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
18723 "Last: " + (data.last_scan_timestamp || "—") +
18724 " — " + (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.";
18725 historyBadge.className = "path-history-badge found";
18726 historyBadge.style.display = "";
18727 }
18728 } else {
18729 if (historyBadge) historyBadge.style.display = "none";
18730 }
18731 })
18732 .catch(function () {});
18733 }
18734
18735 function onPathChange() {
18736 var val = pathInput ? pathInput.value : "";
18737 // Discard stale upload sizes when the user edits the path manually.
18738 window._lastUploadSizes = null;
18739 updateReportTitleFromPath();
18740 autoSetOutputDir(val);
18741 updateSidebarSummary();
18742 clearTimeout(historyTimer);
18743 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
18744 if (previewTimer) clearTimeout(previewTimer);
18745 previewTimer = setTimeout(loadPreview, 280);
18746 suggestCoverageFile(val);
18747 }
18748
18749 if (pathInput) {
18750 pathInput.addEventListener("input", onPathChange);
18751 }
18752
18753 if (outputDirInput) {
18754 outputDirInput.addEventListener("input", function () {
18755 outputDirInput.dataset.userEdited = "1";
18756 syncStripOutputRoot();
18757 updateReview();
18758 });
18759 }
18760
18761 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
18762 if (!node) return;
18763 node.addEventListener("input", function () {
18764 updateReview();
18765 if (previewTimer) clearTimeout(previewTimer);
18766 previewTimer = setTimeout(loadPreview, 280);
18767 });
18768 });
18769
18770 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
18771 var node = document.getElementById(id);
18772 if (node) node.addEventListener("change", updateReview);
18773 });
18774
18775 if (reportTitleInput) {
18776 reportTitleInput.addEventListener("input", function () {
18777 reportTitleTouched = reportTitleInput.value.trim().length > 0;
18778 updateReportTitleFromPath();
18779 updateReview();
18780 });
18781 }
18782
18783 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
18784 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
18785 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
18786 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
18787
18788 if (coverageInput) {
18789 coverageInput.addEventListener("input", function () {
18790 if (coverageInput.value.trim()) setCovStatus("idle");
18791 });
18792 }
18793
18794 if (form && loading && submitButton) {
18795 form.addEventListener("submit", function (e) {
18796 e.preventDefault();
18797 submitButton.disabled = true;
18798 submitButton.textContent = "Scanning...";
18799 startAsyncAnalysis(new FormData(form));
18800 });
18801 }
18802
18803 function openPath(folder) {
18804 if (!folder) return;
18805 fetch('/open-path?path=' + encodeURIComponent(folder))
18806 .then(function (r) { return r.json(); })
18807 .then(function (d) {
18808 if (d && d.server_mode_disabled)
18809 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18810 })
18811 .catch(function () {});
18812 }
18813
18814 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18815 btn.addEventListener('click', function () {
18816 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
18817 });
18818 });
18819
18820 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
18821 if (wsOutputLink) {
18822 wsOutputLink.addEventListener('click', function () {
18823 openPath(wsOutputLink.dataset.folder || '');
18824 });
18825 }
18826
18827 loadSavedTheme();
18828 updateMixedPolicyUI();
18829 updatePythonDocstringUI();
18830 applyScanPreset();
18831 updatePresetDescriptions();
18832 applyArtifactPreset();
18833 updateReview();
18834 updateScrollProgress(); // initialise bar to 0% (step 1)
18835 window.addEventListener("scroll", updateScrollProgress, { passive: true });
18836 onPathChange(); // seed output dir, history badge, and preview from initial path
18837 updateStepNav(1);
18838
18839 // Restore step from URL hash on initial load (e.g., back-forward cache)
18840 (function() {
18841 var hashMatch = location.hash.match(/^#step([1-4])$/);
18842 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
18843 })();
18844
18845 (function randomizeWatermarks() {
18846 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
18847 if (!wms.length) return;
18848 var placed = [];
18849 function tooClose(top, left) {
18850 for (var i = 0; i < placed.length; i++) {
18851 var dt = Math.abs(placed[i][0] - top);
18852 var dl = Math.abs(placed[i][1] - left);
18853 if (dt < 16 && dl < 12) return true;
18854 }
18855 return false;
18856 }
18857 function pick(leftBand) {
18858 for (var attempt = 0; attempt < 50; attempt++) {
18859 var top = Math.random() * 88 + 2;
18860 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18861 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18862 }
18863 var top = Math.random() * 88 + 2;
18864 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18865 placed.push([top, left]);
18866 return [top, left];
18867 }
18868 var half = Math.floor(wms.length / 2);
18869 wms.forEach(function (img, i) {
18870 var pos = pick(i < half);
18871 var size = Math.floor(Math.random() * 80 + 110);
18872 var rot = (Math.random() * 360).toFixed(1);
18873 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
18874 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;
18875 });
18876 })();
18877
18878 (function spawnCodeParticles() {
18879 var container = document.getElementById('code-particles');
18880 if (!container) return;
18881 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'];
18882 for (var i = 0; i < 38; i++) {
18883 (function(idx) {
18884 var el = document.createElement('span');
18885 el.className = 'code-particle';
18886 el.textContent = snippets[idx % snippets.length];
18887 var left = Math.random() * 94 + 2;
18888 var top = Math.random() * 88 + 6;
18889 var dur = (Math.random() * 10 + 9).toFixed(1);
18890 var delay = (Math.random() * 18).toFixed(1);
18891 var rot = (Math.random() * 26 - 13).toFixed(1);
18892 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18893 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';
18894 container.appendChild(el);
18895 })(i);
18896 }
18897 })();
18898 })();
18899 </script>
18900 <script nonce="{{ csp_nonce }}">
18901 (function () {
18902 var raw = {{ prefill_json|safe }};
18903 if (!raw || typeof raw !== 'object' || !raw.path) return;
18904 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
18905 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
18906 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
18907 setVal('path', raw.path || '');
18908 setVal('include_globs', raw.include_globs || '');
18909 setVal('exclude_globs', raw.exclude_globs || '');
18910 setVal('output_dir', raw.output_dir || '');
18911 setVal('report_title', raw.report_title || '');
18912 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
18913 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
18914 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
18915 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
18916 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
18917 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
18918 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
18919 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
18920 setChecked('generate_html', raw.generate_html !== false);
18921 setChecked('generate_pdf', !!raw.generate_pdf);
18922 // Trigger dynamic UI updates after pre-fill.
18923 setTimeout(function () {
18924 var pathEl = document.getElementById('path');
18925 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
18926 var policyEl = document.getElementById('mixed_line_policy');
18927 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
18928 }, 80);
18929 })();
18930 </script>
18931 <script nonce="{{ csp_nonce }}">
18932 (function(){
18933 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'}];
18934 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);});}
18935 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18936 function init(){
18937 var btn=document.getElementById('settings-btn');if(!btn)return;
18938 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18939 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>';
18940 document.body.appendChild(m);
18941 var g=document.getElementById('scheme-grid');
18942 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);});
18943 var cl=document.getElementById('settings-close');
18944 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);
18945 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');});
18946 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18947 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18948 }
18949 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18950 }());
18951 </script>
18952 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
18953 <div class="wb-ftip-arrow"></div>
18954 <span id="wb-ftip-text"></span>
18955 </div>
18956 <script nonce="{{ csp_nonce }}">(function(){
18957 var tip=document.getElementById('wb-ftip');
18958 var txt=document.getElementById('wb-ftip-text');
18959 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
18960 if(!tip||!txt)return;
18961 function pos(el){
18962 var r=el.getBoundingClientRect();
18963 tip.style.display='block';
18964 var tw=tip.offsetWidth;
18965 var lx=r.left+r.width/2-tw/2;
18966 if(lx<8)lx=8;
18967 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
18968 tip.style.left=lx+'px';
18969 tip.style.top=(r.bottom+8)+'px';
18970 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';}
18971 }
18972 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
18973 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
18974 el.addEventListener('mouseleave',function(){tip.style.display='none';});
18975 });
18976 window.addEventListener('blur',function(){tip.style.display='none';});
18977 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
18978 })();
18979 (function(){
18980 function fixArtifactHintSpacing(){
18981 var grid=document.querySelector('.artifact-grid');
18982 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
18983 }
18984 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
18985 }());
18986 (function(){
18987 var dot=document.getElementById('status-dot');
18988 var pingEl=document.getElementById('server-ping-ms');
18989 var tipEl=document.getElementById('server-tip-ping');
18990 var fm=document.getElementById('footer-mode');
18991 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)';}}
18992 function doPing(){
18993 var t0=performance.now();
18994 fetch('/healthz',{cache:'no-store'})
18995 .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);})
18996 .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)';}});
18997 }
18998 doPing();
18999 setInterval(doPing,5000);
19000 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
19001 })();
19002 </script>
19003 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
19004 <footer class="site-footer">
19005 local code analysis - metrics, history and reports
19006 · <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>
19007 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19008 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19009 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19010 · <a href="/api-docs" rel="noopener">REST API</a>
19011 </footer>
19012</body>
19013</html>
19014"##,
19015 ext = "html"
19016)]
19017struct IndexTemplate {
19018 version: &'static str,
19019 prefill_json: String,
19020 csp_nonce: String,
19021 git_repo: String,
19022 git_ref: String,
19023 git_label_json: String,
19024 git_output_dir_json: String,
19025 server_mode: bool,
19026}
19027
19028#[derive(Template)]
19031#[template(
19032 source = r##"
19033<!doctype html>
19034<html lang="en">
19035<head>
19036 <meta charset="utf-8">
19037 <meta name="viewport" content="width=device-width, initial-scale=1">
19038 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
19039 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19040 <script type="application/ld+json">
19041 {
19042 "@context": "https://schema.org",
19043 "@type": "SoftwareApplication",
19044 "name": "oxide-sloc",
19045 "applicationCategory": "DeveloperApplication",
19046 "operatingSystem": "Windows, Linux",
19047 "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.",
19048 "softwareVersion": "{{ version }}",
19049 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
19050 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
19051 "url": "https://github.com/oxide-sloc/oxide-sloc",
19052 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
19053 "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",
19054 "programmingLanguage": "Rust",
19055 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
19056 }
19057 </script>
19058 <style nonce="{{ csp_nonce }}">
19059 :root {
19060 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19061 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19062 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19063 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19064 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19065 }
19066 body.dark-theme {
19067 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19068 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19069 }
19070 *{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;}
19071 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19072 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19073 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19074 .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;}
19075 @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));}}
19076 .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);}
19077 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19078 .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));}
19079 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19080 .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;}
19081 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19082 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19083 @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; } }
19084 .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;}
19085 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19086 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19087 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19088 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19089 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19090 .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;}
19091 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19092 .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);}
19093 .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;}
19094 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19095 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19096 .settings-modal-body{padding:14px 16px 16px;}
19097 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19098 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19099 .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;}
19100 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19101 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19102 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19103 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19104 .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;}
19105 .tz-select:focus{border-color:var(--oxide);}
19106 .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;}
19107 .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;}
19108 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
19109 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
19110 .hero{text-align:center;margin:0 auto 18px;}
19111 .hero-logo-wrap{display:inline-block;cursor:default;}
19112 .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;}
19113 .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;}
19114 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
19115 .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;}
19116 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%);}
19117 .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;
19118 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
19119 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
19120 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;}
19121 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
19122 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
19123 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;}
19124 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
19125 .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;}
19126 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
19127 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
19128 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
19129 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
19130 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
19131 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
19132 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
19133 .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;}
19134 .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;}
19135 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19136 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
19137 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19138 .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);}
19139 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
19140 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
19141 .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);}
19142 .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);}
19143 .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);}
19144 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
19145 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
19146 .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;}
19147 body.dark-theme .action-card-cta{color:var(--oxide);}
19148 .action-card.view .action-card-cta{color:var(--accent-2);}
19149 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
19150 .action-card.compare .action-card-cta{color:#7c3aed;}
19151 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
19152 .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);}
19153 .action-card.git-tools .action-card-cta{color:#15803d;}
19154 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
19155 .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);}
19156 .action-card.trend .action-card-cta{color:#0e7490;}
19157 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
19158 .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);}
19159 .action-card.automation .action-card-cta{color:#b45309;}
19160 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
19161 .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);}
19162 .action-card.test-metrics .action-card-cta{color:#be185d;}
19163 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
19164 .action-card:hover .action-card-cta{gap:12px;}
19165 .action-card.card-split{flex-direction:row;align-items:stretch;}
19166 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
19167 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
19168 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
19169 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
19170 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
19171 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
19172 .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;}
19173 .ac-badge.active{opacity:1;}
19174 .ac-badge.github{border-color:#555;color:#555;}
19175 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
19176 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
19177 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
19178 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
19179 body.dark-theme .ac-right-row{color:var(--muted);}
19180 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
19181 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
19182 .divider{height:1px;background:var(--line);margin:32px 0;}
19183 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
19184 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
19185 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
19186 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
19187 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
19188 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19189 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
19190 body.dark-theme .info-chip-val{color:var(--oxide);}
19191 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
19192 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
19193 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
19194 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
19195 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
19196 border:6px solid transparent;border-top-color:var(--text);}
19197 .info-chip:hover .info-chip-tip{display:block;}
19198 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
19199 .chip-slide.fading{filter:blur(5px);opacity:0;}
19200 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19201 .site-footer a{color:var(--muted);}
19202 .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;}
19203 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
19204 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
19205 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
19206 .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;}
19207 .lan-badge.local{background:var(--oxide-2);}
19208 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
19209 .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);}
19210 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
19211 .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;}
19212 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
19213 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
19214 .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;}
19215 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
19216 .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;}
19217 .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);}
19218 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
19219 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
19220 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
19221 .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;}
19222 @media (max-height: 1100px) {
19223 .page{padding-top:10px;}
19224 .hero{margin-bottom:10px;}
19225 .hero-logo{width:54px;height:60px;}
19226 .hero-logo-shadow{width:42px;}
19227 .hero-title{font-size:28px;}
19228 .hero-subtitle{font-size:13px;}
19229 .card-sections{gap:12px;margin-bottom:6px;}
19230 .card-section-grid-2,.card-section-grid-3{gap:10px;}
19231 .action-card{padding:8px 15px 8px;}
19232 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
19233 .action-card-icon svg{width:18px;height:18px;}
19234 .action-card-title{font-size:13px;}
19235 .action-card-desc{font-size:11px;margin-bottom:6px;}
19236 .action-card-cta{font-size:11px;}
19237 .ac-right-row{font-size:11px;}
19238 .divider{margin:14px 0;}
19239 .info-strip{gap:7px;margin-bottom:8px;}
19240 .info-chip{padding:7px 10px;}
19241 .info-chip-val{font-size:13px;}
19242 .info-chip-label{font-size:9px;}
19243 .site-footer{padding:8px 24px;font-size:12px;}
19244 .lan-local-hint{margin-top:8px;}
19245 }
19246 @media (max-height: 850px) {
19247 .page{padding-top:6px;}
19248 .hero{margin-bottom:6px;}
19249 .hero-logo{width:42px;height:46px;}
19250 .hero-title{font-size:22px;}
19251 .hero-subtitle{font-size:12px;}
19252 .card-sections{gap:10px;}
19253 .action-card-desc{margin-bottom:4px;}
19254 .divider{margin:8px 0;}
19255 .info-strip{margin-bottom:6px;}
19256 .lan-local-hint{margin-top:10px;}
19257 }
19258 </style>
19259</head>
19260<body>
19261 <div class="background-watermarks" aria-hidden="true">
19262 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19263 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19264 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19265 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19266 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19267 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19268 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19269 </div>
19270 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19271 <div class="top-nav">
19272 <div class="top-nav-inner">
19273 <a class="brand" href="/">
19274 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19275 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19276 </a>
19277 <div class="nav-right">
19278 <a class="nav-pill" href="/">Home</a>
19279 <div class="nav-dropdown">
19280 <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>
19281 <div class="nav-dropdown-menu">
19282 <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>
19283 </div>
19284 </div>
19285 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19286 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19287 <div class="nav-dropdown">
19288 <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>
19289 <div class="nav-dropdown-menu">
19290 <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>
19291 </div>
19292 </div>
19293 <div class="server-status-wrap" id="server-status-wrap">
19294 <div class="nav-pill server-online-pill" id="server-status-pill">
19295 <span class="status-dot" id="status-dot"></span>
19296 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19297 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19298 </div>
19299 <div class="server-status-tip">
19300 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
19301 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19302 </div>
19303 </div>
19304 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19305 <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>
19306 </button>
19307 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19308 <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>
19309 <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>
19310 </button>
19311 </div>
19312 </div>
19313 </div>
19314
19315 <div class="page">
19316 <div class="hero">
19317 <div class="hero-logo-wrap" id="hero-logo-wrap">
19318 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
19319 </div>
19320 <div class="hero-logo-shadow"></div>
19321 <div class="hero-title-wrap">
19322 <div class="hero-title-aura" aria-hidden="true"></div>
19323 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
19324 </div>
19325 <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>
19326 </div>
19327
19328 <div class="card-sections">
19329
19330 <div>
19331 <div class="card-section-label">Analysis</div>
19332 <div class="card-section-grid-2">
19333 <a class="action-card scan card-split" href="/scan-setup">
19334 <div class="action-card-left">
19335 <div class="action-card-icon">
19336 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19337 </div>
19338 <div class="action-card-title">Scan Project</div>
19339 <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>
19340 <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>
19341 </div>
19342 <div class="action-card-sep"></div>
19343 <div class="action-card-right">
19344 <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>
19345 <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>
19346 <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>
19347 <div class="ac-right-stat" id="acp-scan-stat"></div>
19348 </div>
19349 </a>
19350 <a class="action-card test-metrics card-split" href="/test-metrics">
19351 <div class="action-card-left">
19352 <div class="action-card-icon">
19353 <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>
19354 </div>
19355 <div class="action-card-title">Test Metrics</div>
19356 <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>
19357 <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>
19358 </div>
19359 <div class="action-card-sep"></div>
19360 <div class="action-card-right">
19361 <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>
19362 <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>
19363 <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>
19364 <div class="ac-right-stat" id="acp-test-stat"></div>
19365 </div>
19366 </a>
19367 </div>
19368 </div>
19369
19370 <div>
19371 <div class="card-section-label">Reports & Insights</div>
19372 <div class="card-section-grid-3">
19373 <a class="action-card view" href="/view-reports">
19374 <div class="action-card-icon">
19375 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
19376 </div>
19377 <div class="action-card-title">View Reports</div>
19378 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
19379 <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>
19380 </a>
19381 <a class="action-card compare" href="/compare-scans">
19382 <div class="action-card-icon">
19383 <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>
19384 </div>
19385 <div class="action-card-title">Compare Scans</div>
19386 <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>
19387 <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>
19388 </a>
19389 <a class="action-card trend" href="/trend-reports">
19390 <div class="action-card-icon">
19391 <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>
19392 </div>
19393 <div class="action-card-title">Trend Report</div>
19394 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
19395 <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>
19396 </a>
19397 </div>
19398 </div>
19399
19400 <div>
19401 <div class="card-section-label">Developer Tools</div>
19402 <div class="card-section-grid-2">
19403 <a class="action-card git-tools card-split" href="/git-browser">
19404 <div class="action-card-left">
19405 <div class="action-card-icon">
19406 <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>
19407 </div>
19408 <div class="action-card-title">Git Browser</div>
19409 <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>
19410 <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>
19411 </div>
19412 <div class="action-card-sep"></div>
19413 <div class="action-card-right">
19414 <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>
19415 <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>
19416 <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>
19417 </div>
19418 </a>
19419 <a class="action-card automation card-split" href="/integrations">
19420 <div class="action-card-left">
19421 <div class="action-card-icon">
19422 <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>
19423 </div>
19424 <div class="action-card-title">Integrations</div>
19425 <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>
19426 <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>
19427 </div>
19428 <div class="action-card-sep"></div>
19429 <div class="action-card-right">
19430 <div class="ac-badges-grid">
19431 <span class="ac-badge github" id="acp-gh">GitHub</span>
19432 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
19433 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
19434 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
19435 </div>
19436 <div class="ac-right-stat" id="acp-int-stat"></div>
19437 </div>
19438 </a>
19439 </div>
19440 </div>
19441
19442 </div>
19443
19444 {% if server_mode %}
19445 <div class="lan-card server">
19446 <div class="lan-card-header">
19447 <span class="lan-badge">LAN server</span>
19448 Accessible on your network
19449 </div>
19450 {% if let Some(ip) = lan_ip %}
19451 <div class="lan-url-row">
19452 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
19453 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
19454 <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>
19455 Copy URL
19456 </button>
19457 </div>
19458 <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>
19459 {% if has_api_key %}
19460 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
19461 {% endif %}
19462 {% else %}
19463 <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>
19464 {% endif %}
19465 </div>
19466 {% endif %}
19467
19468 <div class="divider"></div>
19469
19470 <div class="info-strip">
19471 <div class="info-chip">
19472 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
19473 <div class="chip-slide">
19474 <div class="info-chip-val">60</div>
19475 <div class="info-chip-label">Languages</div>
19476 </div>
19477 </div>
19478 <div class="info-chip">
19479 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
19480 <div class="chip-slide">
19481 <div class="info-chip-val">100%</div>
19482 <div class="info-chip-label">Self-contained</div>
19483 </div>
19484 </div>
19485 <div class="info-chip">
19486 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
19487 <div class="chip-slide">
19488 <div class="info-chip-val">HTML+PDF</div>
19489 <div class="info-chip-label">Exportable reports</div>
19490 </div>
19491 </div>
19492 <div class="info-chip">
19493 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
19494 <div class="chip-slide">
19495 <div class="info-chip-val">Webhook</div>
19496 <div class="info-chip-label">3 platforms</div>
19497 </div>
19498 </div>
19499 <div class="info-chip">
19500 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
19501 <div class="chip-slide">
19502 <div class="info-chip-val">IEEE</div>
19503 <div class="info-chip-label">1045-1992</div>
19504 </div>
19505 </div>
19506 </div>
19507
19508 {% if lan_ip.is_none() %}
19509 <div class="lan-local-hint">
19510 <strong>Want teammates on the same network to access this?</strong><br>
19511 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
19512 </div>
19513 {% endif %}
19514 </div>
19515
19516 <footer class="site-footer">
19517 local code analysis - metrics, history and reports
19518 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19519 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19520 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19521 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19522 · <a href="/api-docs" rel="noopener">REST API</a>
19523 </footer>
19524
19525 <script nonce="{{ csp_nonce }}">
19526 (function () {
19527 var storageKey = 'oxide-sloc-theme';
19528 var body = document.body;
19529 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19530 var toggle = document.getElementById('theme-toggle');
19531 if (toggle) toggle.addEventListener('click', function () {
19532 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19533 body.classList.toggle('dark-theme', next === 'dark');
19534 try { localStorage.setItem(storageKey, next); } catch(e) {}
19535 });
19536 var copyBtn = document.getElementById('lan-copy-btn');
19537 if (copyBtn) copyBtn.addEventListener('click', function() {
19538 var btn = this;
19539 var el = document.getElementById('lan-url-val');
19540 if (!el) return;
19541 var url = el.textContent.trim();
19542 if (navigator.clipboard) {
19543 navigator.clipboard.writeText(url).then(function() {
19544 var orig = btn.innerHTML;
19545 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!';
19546 setTimeout(function() { btn.innerHTML = orig; }, 1800);
19547 });
19548 }
19549 });
19550 (function randomizeWatermarks() {
19551 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19552 if (!wms.length) return;
19553 var placed = [];
19554 function tooClose(top, left) {
19555 for (var i = 0; i < placed.length; i++) {
19556 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19557 if (dt < 16 && dl < 12) return true;
19558 }
19559 return false;
19560 }
19561 function pick(leftBand) {
19562 for (var attempt = 0; attempt < 50; attempt++) {
19563 var top = Math.random() * 88 + 2;
19564 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19565 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19566 }
19567 var top = Math.random() * 88 + 2;
19568 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19569 placed.push([top, left]); return [top, left];
19570 }
19571 var half = Math.floor(wms.length / 2);
19572 wms.forEach(function (img, i) {
19573 var pos = pick(i < half);
19574 var size = Math.floor(Math.random() * 100 + 120);
19575 var rot = (Math.random() * 360).toFixed(1);
19576 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19577 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;
19578 });
19579 })();
19580
19581 (function spawnCodeParticles() {
19582 var container = document.getElementById('code-particles');
19583 if (!container) return;
19584 var snippets = [
19585 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19586 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19587 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19588 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19589 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19590 ];
19591 var count = 38;
19592 for (var i = 0; i < count; i++) {
19593 (function(idx) {
19594 var el = document.createElement('span');
19595 el.className = 'code-particle';
19596 var text = snippets[idx % snippets.length];
19597 el.textContent = text;
19598 var left = Math.random() * 94 + 2;
19599 var top = Math.random() * 88 + 6;
19600 var dur = (Math.random() * 10 + 9).toFixed(1);
19601 var delay = (Math.random() * 18).toFixed(1);
19602 var rot = (Math.random() * 26 - 13).toFixed(1);
19603 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19604 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
19605 + '--rot:' + rot + 'deg;--op:' + op + ';'
19606 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
19607 container.appendChild(el);
19608 })(i);
19609 }
19610 })();
19611 (function heroAnimations() {
19612 var sub = document.getElementById('hero-subtitle');
19613 if (sub) {
19614 var full = sub.textContent.trim();
19615 sub.textContent = '';
19616 sub.style.opacity = '1';
19617 var cursor = document.createElement('span');
19618 cursor.className = 'hero-cursor';
19619 sub.appendChild(cursor);
19620 var i = 0;
19621 setTimeout(function() {
19622 var iv = setInterval(function() {
19623 if (i < full.length) {
19624 sub.insertBefore(document.createTextNode(full[i]), cursor);
19625 i++;
19626 } else {
19627 clearInterval(iv);
19628 setTimeout(function() {
19629 cursor.style.transition = 'opacity 1s ease';
19630 cursor.style.opacity = '0';
19631 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
19632 }, 2400);
19633 }
19634 }, 11);
19635 }, 374);
19636 }
19637 })();
19638 (function logoBob() {
19639 var logo = document.querySelector('.hero-logo');
19640 var shadow = document.querySelector('.hero-logo-shadow');
19641 if (!logo) return;
19642 var cycleStart = null, cycleDur = 3600;
19643 var peakY = -14, peakScale = 1.07, peakRot = 0;
19644 function newCycle() {
19645 cycleDur = 3000 + Math.random() * 1840;
19646 peakY = -(9 + Math.random() * 13.8);
19647 peakScale = 1.04 + Math.random() * 0.081;
19648 peakRot = (Math.random() * 11.5 - 5.75);
19649 }
19650 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
19651 newCycle();
19652 function frame(ts) {
19653 if (cycleStart === null) cycleStart = ts;
19654 var t = (ts - cycleStart) / cycleDur;
19655 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
19656 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
19657 var y = peakY * phase;
19658 var sc = 1 + (peakScale - 1) * phase;
19659 var rot = peakRot * Math.sin(Math.PI * phase);
19660 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
19661 if (shadow) {
19662 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
19663 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
19664 }
19665 requestAnimationFrame(frame);
19666 }
19667 requestAnimationFrame(frame);
19668 })();
19669 (function mouseEffects() {
19670 var heroTitle = document.getElementById('hero-title');
19671 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
19672 function tick() {
19673 raf = null;
19674 if (heroTitle) {
19675 var r = heroTitle.getBoundingClientRect();
19676 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
19677 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
19678 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
19679 }
19680 }
19681 document.addEventListener('mousemove', function(e) {
19682 mx = e.clientX; my = e.clientY;
19683 if (!raf) raf = requestAnimationFrame(tick);
19684 });
19685 document.addEventListener('mouseleave', function() {
19686 if (heroTitle) {
19687 heroTitle.style.transition = 'transform 0.5s ease';
19688 heroTitle.style.transform = '';
19689 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
19690 }
19691 });
19692 document.querySelectorAll('.action-card').forEach(function(card) {
19693 card.addEventListener('mousemove', function(e) {
19694 var rect = card.getBoundingClientRect();
19695 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
19696 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
19697 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
19698 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
19699 });
19700 card.addEventListener('mouseleave', function() {
19701 card.style.transition = '';
19702 card.style.transform = '';
19703 });
19704 });
19705 })();
19706 (function chipSlideshow() {
19707 var slides = [
19708 [{v:'60',l:'Languages'},{v:'Rust · Go · Python',l:'and 57 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
19709 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
19710 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
19711 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
19712 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
19713 ];
19714 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
19715 var indices = [0,0,0,0,0];
19716 var paused = [false,false,false,false,false];
19717 chips.forEach(function(chip, i) {
19718 chip.addEventListener('mouseenter', function() { paused[i] = true; });
19719 chip.addEventListener('mouseleave', function() { paused[i] = false; });
19720 });
19721 function advance(i) {
19722 if (paused[i]) return;
19723 var chip = chips[i];
19724 var inner = chip.querySelector('.chip-slide');
19725 if (!inner) return;
19726 inner.classList.add('fading');
19727 setTimeout(function() {
19728 indices[i] = (indices[i] + 1) % slides[i].length;
19729 var s = slides[i][indices[i]];
19730 chip.querySelector('.info-chip-val').textContent = s.v;
19731 chip.querySelector('.info-chip-label').textContent = s.l;
19732 inner.classList.remove('fading');
19733 }, 720);
19734 }
19735 setInterval(function() {
19736 chips.forEach(function(chip, i) { advance(i); });
19737 }, 6000);
19738 })();
19739 (function cardLiveData() {
19740 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
19741 var el = document.getElementById('acp-scan-stat');
19742 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
19743 }).catch(function(){});
19744 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
19745 var el = document.getElementById('acp-test-stat');
19746 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
19747 }).catch(function(){});
19748 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
19749 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
19750 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
19751 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
19752 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
19753 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
19754 var stat = document.getElementById('acp-int-stat');
19755 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
19756 }).catch(function(){});
19757 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
19758 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
19759 }).catch(function(){});
19760 })();
19761 })();
19762 </script>
19763 <script nonce="{{ csp_nonce }}">
19764 (function(){
19765 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'}];
19766 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);});}
19767 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19768 function init(){
19769 var btn=document.getElementById('settings-btn');if(!btn)return;
19770 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19771 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>';
19772 document.body.appendChild(m);
19773 var g=document.getElementById('scheme-grid');
19774 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);});
19775 var cl=document.getElementById('settings-close');
19776 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);
19777 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');});
19778 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19779 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19780 }
19781 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19782 }());
19783 </script>
19784 <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 }} — 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>
19785</body>
19786</html>
19787"##,
19788 ext = "html"
19789)]
19790struct SplashTemplate {
19791 csp_nonce: String,
19792 server_mode: bool,
19793 lan_ip: Option<String>,
19794 port: u16,
19795 version: &'static str,
19796 has_api_key: bool,
19797}
19798
19799#[derive(Template)]
19802#[template(
19803 source = r##"
19804<!doctype html>
19805<html lang="en">
19806<head>
19807 <meta charset="utf-8">
19808 <meta name="viewport" content="width=device-width, initial-scale=1">
19809 <title>OxideSLOC — Start a Scan</title>
19810 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19811 <style nonce="{{ csp_nonce }}">
19812 :root {
19813 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
19814 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19815 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19816 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19817 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19818 }
19819 body.dark-theme {
19820 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19821 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19822 }
19823 *{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;}
19824 .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);}
19825 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19826 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
19827 .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));}
19828 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
19829 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19830 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19831 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19832 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19833 @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; } }
19834 .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;}
19835 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19836 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19837 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19838 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19839 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19840 .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;}
19841 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19842 .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);}
19843 .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;}
19844 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19845 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19846 .settings-modal-body{padding:14px 16px 16px;}
19847 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19848 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19849 .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;}
19850 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19851 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19852 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19853 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19854 .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;}
19855 .tz-select:focus{border-color:var(--oxide);}
19856 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
19857 .page-header{text-align:center;margin-bottom:16px;}
19858 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
19859 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
19860 /* Cards */
19861 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
19862 .option-card-wrap{position:relative;}
19863 .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;}
19864 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
19865 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19866 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
19867 .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;}
19868 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
19869 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
19870 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
19871 .card-top-row{display:flex;align-items:center;gap:20px;}
19872 /* Two-column layout inside each card */
19873 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
19874 .card-left{display:flex;align-items:flex-start;min-width:0;}
19875 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
19876 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
19877 .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);}
19878 .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);}
19879 .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);}
19880 .card-text{min-width:0;}
19881 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
19882 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
19883 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
19884 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
19885 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
19886 /* Right CTA column */
19887 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
19888 .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;}
19889 /* Re-scan count badge */
19890 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
19891 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
19892 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
19893 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
19894 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
19895 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
19896 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
19897 body.dark-theme .btn-secondary{color:var(--oxide);}
19898 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
19899 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
19900 /* File input overlay — must be full-width so it aligns with other card-right buttons */
19901 .file-input-wrap{position:relative;width:100%;}
19902 .file-input-wrap .btn{width:100%;}
19903 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
19904 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19905 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19906 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19907 .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;}
19908 @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));}}
19909 /* Recent list (card 3 — full-width section below header) */
19910 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
19911 .recent-list{display:flex;flex-direction:column;gap:8px;}
19912 .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;}
19913 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
19914 .recent-item-info{flex:1;min-width:0;}
19915 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
19916 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
19917 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
19918 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
19919 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19920 .site-footer a{color:var(--muted);}
19921 @media(max-width:680px){
19922 .card-body{grid-template-columns:1fr;}
19923 .card-right{flex-direction:row;flex-wrap:wrap;}
19924 .btn{flex:1;}
19925 }
19926 .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;}
19927 .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;}
19928 .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;}
19929 </style>
19930</head>
19931<body>
19932 <div class="background-watermarks" aria-hidden="true">
19933 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19934 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19935 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19936 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19937 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19938 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19939 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19940 </div>
19941 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19942 <div class="top-nav">
19943 <div class="top-nav-inner">
19944 <a class="brand" href="/">
19945 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19946 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19947 </a>
19948 <div class="nav-right">
19949 <a class="nav-pill" href="/">Home</a>
19950 <div class="nav-dropdown">
19951 <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>
19952 <div class="nav-dropdown-menu">
19953 <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>
19954 </div>
19955 </div>
19956 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19957 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19958 <div class="nav-dropdown">
19959 <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>
19960 <div class="nav-dropdown-menu">
19961 <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>
19962 </div>
19963 </div>
19964 <div class="server-status-wrap" id="server-status-wrap">
19965 <div class="nav-pill server-online-pill" id="server-status-pill">
19966 <span class="status-dot" id="status-dot"></span>
19967 <span id="server-status-label">Server</span>
19968 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19969 </div>
19970 <div class="server-status-tip">
19971 OxideSLOC is running — accessible on your network.
19972 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19973 </div>
19974 </div>
19975 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19976 <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>
19977 </button>
19978 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19979 <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>
19980 <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>
19981 </button>
19982 </div>
19983 </div>
19984 </div>
19985
19986 <div class="page">
19987 <div class="page-header">
19988 <h1>How would you like to scan?</h1>
19989 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
19990 </div>
19991
19992 <div class="option-grid">
19993
19994 <!-- Option 1: New scan -->
19995 <div class="option-card-wrap">
19996 <div class="option-card">
19997 <div class="option-icon new-scan">
19998 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19999 </div>
20000 <div class="card-body">
20001 <div class="card-left">
20002 <div class="card-text">
20003 <div class="option-title">Start a new scan</div>
20004 <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>
20005 <ul class="feature-list">
20006 <li>Live project scope preview before you run</li>
20007 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
20008 <li>HTML, PDF, and JSON output — your choice</li>
20009 </ul>
20010 </div>
20011 </div>
20012 <div class="card-right">
20013 <a class="btn btn-primary" href="/scan">
20014 Configure & scan
20015 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
20016 </a>
20017 <p class="card-tip">Full 4-step setup · all options</p>
20018 </div>
20019 </div>
20020 </div>
20021 </div>
20022
20023 <!-- Option 2: Load from config file -->
20024 <div class="option-card-wrap">
20025 <div class="option-card">
20026 <div class="option-icon load-config">
20027 <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>
20028 </div>
20029 <div class="card-body">
20030 <div class="card-left">
20031 <div class="card-text">
20032 <div class="option-title">Load a saved config</div>
20033 <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>
20034 <ul class="feature-list">
20035 <li>All 15 settings restored from the file</li>
20036 <li>Fully editable — change path or output dir</li>
20037 <li>Works with any scan-config.json</li>
20038 </ul>
20039 </div>
20040 </div>
20041 <div class="card-right">
20042 <div class="file-input-wrap">
20043 <button class="btn btn-secondary" id="load-config-btn" type="button">
20044 <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>
20045 Choose config file
20046 </button>
20047 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
20048 </div>
20049 <p class="card-tip" id="config-file-name">Exported after every scan</p>
20050 </div>
20051 </div>
20052 </div>
20053 </div>
20054
20055 <!-- Option 3: Re-scan recent project -->
20056 <div class="option-card-wrap">
20057 <div class="option-card" id="recent-card">
20058 <div class="card-top-row">
20059 <div class="option-icon rescan">
20060 <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>
20061 </div>
20062 <div class="card-body">
20063 <div class="card-left">
20064 <div class="card-text">
20065 <div class="option-title">Re-scan a recent project</div>
20066 <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>
20067 <ul class="feature-list">
20068 <li>All 15+ settings restored from the saved config</li>
20069 <li>Path and output dir are editable before running</li>
20070 <li>Only scans with a saved config appear here</li>
20071 </ul>
20072 </div>
20073 </div>
20074 <div class="card-right">
20075 <div class="rescan-count-box">
20076 <div class="rescan-count-num" id="rescan-count-num">—</div>
20077 <div class="rescan-count-label">saved configs</div>
20078 </div>
20079 <a class="btn btn-secondary" href="/view-reports">
20080 <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>
20081 View all runs
20082 </a>
20083 <p class="card-tip">Opens run history</p>
20084 </div>
20085 </div>
20086 </div>
20087 <div class="section-divider"></div>
20088 <div class="recent-list" id="recent-list">
20089 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
20090 </div>
20091 </div>
20092 </div>
20093
20094 </div>
20095 </div>
20096
20097 <footer class="site-footer">
20098 local code analysis - metrics, history and reports
20099 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20100 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20101 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20102 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20103 · <a href="/api-docs" rel="noopener">REST API</a>
20104 </footer>
20105
20106 <script nonce="{{ csp_nonce }}">
20107 (function () {
20108 var storageKey = 'oxide-sloc-theme';
20109 var body = document.body;
20110 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20111 var toggle = document.getElementById('theme-toggle');
20112 if (toggle) toggle.addEventListener('click', function () {
20113 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20114 body.classList.toggle('dark-theme', next === 'dark');
20115 try { localStorage.setItem(storageKey, next); } catch(e) {}
20116 });
20117
20118 (function randomizeWatermarks() {
20119 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20120 if (!wms.length) return;
20121 var placed = [];
20122 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; }
20123 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]; }
20124 var half = Math.floor(wms.length / 2);
20125 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; });
20126 })();
20127 (function spawnCodeParticles() {
20128 var container = document.getElementById('code-particles');
20129 if (!container) return;
20130 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'];
20131 var count = 38;
20132 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); }
20133 })();
20134 // Recent scans data injected from server
20135 var recentScans = {{ recent_scans_json|safe }};
20136
20137 function configToParams(cfg) {
20138 var p = new URLSearchParams();
20139 p.set('prefilled', '1');
20140 if (cfg.path) p.set('path', cfg.path);
20141 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
20142 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
20143 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
20144 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
20145 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
20146 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
20147 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
20148 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
20149 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
20150 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
20151 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
20152 if (cfg.report_title) p.set('report_title', cfg.report_title);
20153 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
20154 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
20155 return p;
20156 }
20157
20158 // Build recent scan list (capped at 3 visible entries)
20159 var list = document.getElementById('recent-list');
20160 var noNote = document.getElementById('no-recent-note');
20161 var hasAny = false;
20162 var MAX_RECENT = 3;
20163 if (Array.isArray(recentScans)) {
20164 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
20165 var shown = 0;
20166 validEntries.forEach(function (entry) {
20167 if (shown >= MAX_RECENT) return;
20168 shown++;
20169 hasAny = true;
20170 var item = document.createElement('div');
20171 item.className = 'recent-item';
20172 item.title = 'Restore all settings and open wizard';
20173 item.innerHTML =
20174 '<div class="recent-item-info">' +
20175 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
20176 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
20177 '</div>' +
20178 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
20179 item.addEventListener('click', function () {
20180 var params = configToParams(entry.config);
20181 window.location.href = '/scan?' + params.toString();
20182 });
20183 list.appendChild(item);
20184 });
20185 if (validEntries.length > MAX_RECENT) {
20186 var moreEl = document.createElement('div');
20187 moreEl.className = 'recent-more-link';
20188 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
20189 list.appendChild(moreEl);
20190 }
20191 }
20192 if (hasAny && noNote) noNote.style.display = 'none';
20193 // Update count badge
20194 var countEl = document.getElementById('rescan-count-num');
20195 if (countEl) {
20196 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
20197 countEl.textContent = total > 0 ? total : '0';
20198 }
20199
20200 // Config file loader
20201 var fileInput = document.getElementById('config-file-input');
20202 var fileName = document.getElementById('config-file-name');
20203 var loadBtn = document.getElementById('load-config-btn');
20204 // Wire the visible button to open the hidden file picker.
20205 if (loadBtn && fileInput) {
20206 loadBtn.addEventListener('click', function () { fileInput.click(); });
20207 }
20208 if (fileInput) {
20209 fileInput.addEventListener('change', function () {
20210 var file = fileInput.files && fileInput.files[0];
20211 if (!file) return;
20212 if (fileName) fileName.textContent = '✓ ' + file.name;
20213 var reader = new FileReader();
20214 reader.onload = function (e) {
20215 try {
20216 var cfg = JSON.parse(e.target.result);
20217 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
20218 var params = configToParams(cfg);
20219 window.location.href = '/scan?' + params.toString();
20220 } catch (err) {
20221 alert('Could not parse config file: ' + err.message);
20222 }
20223 };
20224 reader.readAsText(file);
20225 });
20226 }
20227
20228 function escHtml(s) {
20229 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
20230 }
20231 })();
20232 </script>
20233 <script nonce="{{ csp_nonce }}">
20234 (function(){
20235 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'}];
20236 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);});}
20237 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20238 function init(){
20239 var btn=document.getElementById('settings-btn');if(!btn)return;
20240 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20241 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>';
20242 document.body.appendChild(m);
20243 var g=document.getElementById('scheme-grid');
20244 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);});
20245 var cl=document.getElementById('settings-close');
20246 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);
20247 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');});
20248 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20249 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20250 }
20251 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20252 }());
20253 </script>
20254 <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]';
20255 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;}
20256 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>
20257</body>
20258</html>
20259"##,
20260 ext = "html"
20261)]
20262struct ScanSetupTemplate {
20263 version: &'static str,
20264 recent_scans_json: String,
20265 csp_nonce: String,
20266}
20267
20268#[derive(Template)]
20269#[template(
20270 source = r##"
20271<!doctype html>
20272<html lang="en">
20273<head>
20274 <meta charset="utf-8">
20275 <meta name="viewport" content="width=device-width, initial-scale=1">
20276 <title>OxideSLOC | {{ report_title }} | Report</title>
20277 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20278 <style nonce="{{ csp_nonce }}">
20279 :root {
20280 --radius: 18px;
20281 --bg: #f5efe8;
20282 --surface: rgba(255,255,255,0.82);
20283 --surface-2: #fbf7f2;
20284 --surface-3: #efe6dc;
20285 --line: #e6d0bf;
20286 --line-strong: #dcb89f;
20287 --text: #43342d;
20288 --muted: #7b675b;
20289 --muted-2: #a08777;
20290 --nav: #b85d33;
20291 --nav-2: #7a371b;
20292 --accent: #6f9bff;
20293 --accent-2: #4a78ee;
20294 --oxide: #d37a4c;
20295 --oxide-2: #b35428;
20296 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
20297 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
20298 --success-bg: #e8f5ed;
20299 --success-text: #1a8f47;
20300 --info-bg: #eef3ff;
20301 --info-text: #4467d8;
20302 }
20303
20304 body.dark-theme {
20305 --bg: #1b1511;
20306 --surface: #261c17;
20307 --surface-2: #2d221d;
20308 --surface-3: #372922;
20309 --line: #524238;
20310 --line-strong: #6c5649;
20311 --text: #f5ece6;
20312 --muted: #c7b7aa;
20313 --muted-2: #aa9485;
20314 --nav: #b85d33;
20315 --nav-2: #7a371b;
20316 --accent: #6f9bff;
20317 --accent-2: #4a78ee;
20318 --oxide: #d37a4c;
20319 --oxide-2: #b35428;
20320 --shadow: 0 18px 42px rgba(0,0,0,0.28);
20321 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
20322 --success-bg: #163927;
20323 --success-text: #8fe2a8;
20324 --info-bg: #1c2847;
20325 --info-text: #a9c1ff;
20326 }
20327
20328 * { box-sizing: border-box; }
20329 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); }
20330 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
20331 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
20332 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
20333 .top-nav, .page { position: relative; z-index: 2; }
20334 .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); }
20335 .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; }
20336 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
20337 .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)); }
20338 .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; }
20339 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
20340 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
20341 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
20342 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
20343 .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; }
20344 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
20345 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20346 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
20347 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20348 @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; } }
20349 .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; }
20350 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
20351 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
20352 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
20353 .theme-toggle .icon-sun { display:none; }
20354 body.dark-theme .theme-toggle .icon-sun { display:block; }
20355 body.dark-theme .theme-toggle .icon-moon { display:none; }
20356 .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;}
20357 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20358 .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);}
20359 .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;}
20360 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20361 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20362 .settings-modal-body{padding:14px 16px 16px;}
20363 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20364 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20365 .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;}
20366 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20367 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20368 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20369 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20370 .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;}
20371 .tz-select:focus{border-color:var(--oxide);}
20372 .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; }
20373 .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;}
20374 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
20375 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
20376 .hero, .panel { padding: 22px; }
20377 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
20378 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
20379 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
20380 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
20381 .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; }
20382 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
20383 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
20384 .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; }
20385 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
20386 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
20387 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
20388 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
20389 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
20390 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
20391 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; min-width:92px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; }
20392 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
20393 .delta-card-val { font-size:16px; font-weight:800; }
20394 .delta-card-val.pos { color:#1e7e34; }
20395 .delta-card-val.neg { color:var(--neg); }
20396 .delta-card-val.mod { color:#b35428; }
20397 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
20398 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .2s ease; z-index:200; }
20399 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20400 .delta-card-inline:hover .delta-card-tip { opacity:1; }
20401 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
20402 .compare-ts { font-size:13px; color:var(--muted); }
20403 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
20404 .compare-arrow { color: var(--muted); }
20405 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
20406 .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; }
20407 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
20408 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
20409 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
20410 .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; }
20411 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
20412 .run-mgmt-card .action-buttons { justify-content:center; }
20413 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
20414 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
20415 .button, .copy-button {
20416 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;
20417 }
20418 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
20419 @keyframes spin { to { transform: rotate(360deg); } }
20420 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
20421 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
20422 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
20423 .path-item strong { display: block; margin-bottom: 6px; }
20424 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
20425 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
20426 .path-subitem { flex: 1; }
20427 .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); }
20428 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); }
20429 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
20430 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
20431 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
20432 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
20433 th { color: var(--muted); font-weight: 700; }
20434 tr:last-child td { border-bottom: none; }
20435 #subm-tbl col:nth-child(1){width:15%;}
20436 #subm-tbl col:nth-child(2){width:31%;}
20437 #subm-tbl col:nth-child(3){width:9%;}
20438 #subm-tbl col:nth-child(4){width:9%;}
20439 #subm-tbl col:nth-child(5){width:9%;}
20440 #subm-tbl col:nth-child(6){width:9%;}
20441 #subm-tbl col:nth-child(7){width:9%;}
20442 #subm-tbl col:nth-child(8){width:9%;}
20443 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
20444 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
20445 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
20446 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
20447 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
20448 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
20449 .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; }
20450 .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; }
20451 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
20452 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
20453 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
20454 .muted { color: var(--muted); }
20455 /* Run-ID chip row (mirrors HTML report) */
20456 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
20457 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
20458 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
20459 .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; }
20460 .run-id-chip[data-copy] { cursor:pointer; }
20461 a.run-id-chip { text-decoration:none; cursor:pointer; }
20462 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
20463 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
20464 .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; }
20465 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
20466 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20467 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
20468 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
20469 a.commit-link-value { color:inherit; text-decoration:none; }
20470 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
20471 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 0.18s ease; z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
20472 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20473 .run-id-chip:hover .chip-tooltip { opacity:1; }
20474 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
20475 .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; }
20476 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
20477 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
20478 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
20479 /* Meta chips row */
20480 .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%; }
20481 .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; }
20482 .meta-chip:last-child { border-right:none; }
20483 .meta-chip b { color:var(--text); font-weight:700; }
20484 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20485 .site-footer a{color:var(--muted);}
20486 .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; }
20487 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
20488 .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; }
20489 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
20490 /* Stat chips (matches HTML report) */
20491 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
20492 @media(max-width:1200px){.summary-strip{grid-template-columns:repeat(4,1fr);}}
20493 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20494 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; overflow:visible; }
20495 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
20496 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
20497 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
20498 .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; }
20499 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%); 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 .2s ease; z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
20500 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20501 .stat-chip:hover .stat-chip-tip { opacity:1; }
20502 .cocomo-box { background:var(--surface-2); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
20503 .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; }
20504 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
20505 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
20506 .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); }
20507 .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; 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 .2s ease; z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
20508 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
20509 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; }
20510 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
20511 /* Submodule panel */
20512 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
20513 /* Metrics tables stack */
20514 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
20515 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
20516 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
20517 .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)); }
20518 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
20519 /* Metrics table */
20520 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
20521 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
20522 .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; }
20523 .metrics-table thead th:not(:first-child) { text-align: right; }
20524 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
20525 .metrics-table tbody tr:last-child td { border-bottom: none; }
20526 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
20527 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
20528 .metrics-table tbody tr:hover td { background: var(--surface-2); }
20529 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
20530 .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; }
20531 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
20532 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
20533 .mt-val-pos { color: var(--pos); font-weight: 700; }
20534 .mt-val-neg { color: var(--neg); font-weight: 700; }
20535 .mt-val-zero { color: var(--muted); }
20536 .mt-val-mod { color: var(--oxide-2); }
20537 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
20538 @media (max-width: 1180px) {
20539 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
20540 .nav-project-slot, .nav-status { justify-content:flex-start; }
20541 .hero-top { flex-direction: column; }
20542 .run-mgmt-strip { flex-direction: column; }
20543 }
20544 .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;}
20545 @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));}}
20546 .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;}
20547 /* ── Result-page chart controls ─────────────────────────────────────────── */
20548 .r-chart-section{margin-bottom:24px;}
20549 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
20550 .section-pair > .panel{flex-shrink:0;}
20551 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
20552 .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;}
20553 .r-chart-select:focus{border-color:var(--accent);}
20554 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
20555 .r-chart-container svg{display:block;width:100%;height:auto;}
20556 .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;}
20557 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
20558 .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;}
20559 .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);}
20560 .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;}
20561 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
20562 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
20563 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
20564 .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;}
20565 .r-chart-modal-close:hover{opacity:.7;}
20566 body.dark-theme .r-chart-modal{background:var(--surface);}
20567 .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;}
20568 .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);}
20569 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
20570 .lang-bar-row:hover{transform:translateY(-2px);}
20571 .lang-bar-row .rchit:hover{filter:none;transform:none;}
20572 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
20573 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
20574 .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;}
20575 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
20576 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
20577 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
20578 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
20579 #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;}
20580 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
20581 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
20582 .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;}
20583 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
20584 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
20585 .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;}
20586 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
20587 .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;}
20588 .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;}
20589 body.has-report-banner .top-nav{top:27px;}
20590 body.has-report-banner{padding-bottom:27px;}
20591 </style>
20592</head>
20593<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
20594 <div class="background-watermarks" aria-hidden="true">
20595 <img src="/images/logo/logo-text.png" alt="" />
20596 <img src="/images/logo/logo-text.png" alt="" />
20597 <img src="/images/logo/logo-text.png" alt="" />
20598 <img src="/images/logo/logo-text.png" alt="" />
20599 <img src="/images/logo/logo-text.png" alt="" />
20600 <img src="/images/logo/logo-text.png" alt="" />
20601 <img src="/images/logo/logo-text.png" alt="" />
20602 <img src="/images/logo/logo-text.png" alt="" />
20603 <img src="/images/logo/logo-text.png" alt="" />
20604 <img src="/images/logo/logo-text.png" alt="" />
20605 <img src="/images/logo/logo-text.png" alt="" />
20606 <img src="/images/logo/logo-text.png" alt="" />
20607 <img src="/images/logo/logo-text.png" alt="" />
20608 <img src="/images/logo/logo-text.png" alt="" />
20609 </div>
20610 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20611 {% if let Some(banner) = report_header_footer %}
20612 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
20613 {% endif %}
20614 <div class="top-nav">
20615 <div class="top-nav-inner">
20616 <a class="brand" href="/">
20617 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20618 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
20619 </a>
20620 <div class="nav-project-slot">
20621 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
20622 </div>
20623 <div class="nav-status">
20624 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
20625 <div class="nav-dropdown">
20626 <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>
20627 <div class="nav-dropdown-menu">
20628 <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>
20629 </div>
20630 </div>
20631 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
20632 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20633 <div class="nav-dropdown">
20634 <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>
20635 <div class="nav-dropdown-menu">
20636 <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>
20637 </div>
20638 </div>
20639 <div class="server-status-wrap" id="server-status-wrap">
20640 <div class="nav-pill server-online-pill" id="server-status-pill">
20641 <span class="status-dot" id="status-dot"></span>
20642 <span id="server-status-label">Server</span>
20643 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20644 </div>
20645 <div class="server-status-tip">
20646 OxideSLOC is running — accessible on your network.
20647 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20648 </div>
20649 </div>
20650 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20651 <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>
20652 </button>
20653 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
20654 <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>
20655 <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>
20656 </button>
20657 </div>
20658 </div>
20659 </div>
20660
20661 <div class="page">
20662 <section class="hero">
20663 <div class="hero-top">
20664 <div>
20665 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
20666 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
20667 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
20668 <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>
20669 </div>
20670 </div>
20671 <div class="hero-quick-actions">
20672 {% if server_mode %}
20673 <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>
20674 {% else %}
20675 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
20676 {% endif %}
20677 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
20678 {% if !server_mode %}
20679 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
20680 {% endif %}
20681 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
20682 <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>
20683 </div>
20684 </div>
20685
20686 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
20687 <div class="run-id-row">
20688 <span class="run-id-chip" data-copy="{{ run_id }}">
20689 <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>
20690 <span class="run-id-chip-value">{{ run_id }}</span>
20691 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
20692 </span>
20693 {% match git_commit_long %}
20694 {% when Some with (long_sha) %}
20695 {% match git_commit_url %}
20696 {% when Some with (commit_url) %}
20697 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
20698 <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>
20699 <span class="run-id-chip-value">{{ long_sha }}</span>
20700 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
20701 </a>
20702 {% when None %}
20703 <span class="run-id-chip" data-copy="{{ long_sha }}">
20704 <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>
20705 <span class="run-id-chip-value">{{ long_sha }}</span>
20706 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
20707 </span>
20708 {% endmatch %}
20709 {% when None %}
20710 <span class="run-id-chip muted-chip">
20711 <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>
20712 <span class="run-id-chip-value">Not detected</span>
20713 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
20714 </span>
20715 {% endmatch %}
20716 {% match git_branch %}
20717 {% when Some with (branch) %}
20718 {% match git_branch_url %}
20719 {% when Some with (branch_url) %}
20720 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
20721 <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>
20722 <span class="run-id-chip-value">{{ branch }}</span>
20723 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
20724 </a>
20725 {% when None %}
20726 <span class="run-id-chip">
20727 <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>
20728 <span class="run-id-chip-value">{{ branch }}</span>
20729 <span class="chip-tooltip">Git branch active at scan time</span>
20730 </span>
20731 {% endmatch %}
20732 {% when None %}
20733 <span class="run-id-chip muted-chip">
20734 <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>
20735 <span class="run-id-chip-value">Not detected</span>
20736 <span class="chip-tooltip">No Git branch was found for this scan</span>
20737 </span>
20738 {% endmatch %}
20739 {% match git_author %}
20740 {% when Some with (author) %}
20741 <span class="run-id-chip" data-author="{{ author }}">
20742 <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>
20743 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
20744 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
20745 </span>
20746 {% when None %}
20747 <span class="run-id-chip muted-chip">
20748 <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>
20749 <span class="run-id-chip-value">Not detected</span>
20750 <span class="chip-tooltip">No commit author was found for this scan</span>
20751 </span>
20752 {% endmatch %}
20753 </div>
20754
20755 <!-- Scan metadata row -->
20756 <div class="meta">
20757 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
20758 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
20759 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
20760 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
20761 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
20762 </div>
20763
20764 <!-- All summary stat chips in one unified strip (8 columns) -->
20765 <div class="summary-strip">
20766 <div class="stat-chip" data-raw="{{ physical_lines }}">
20767 <div class="stat-chip-label">Physical lines</div>
20768 <div class="stat-chip-val">{{ physical_lines }}</div>
20769 <div class="stat-chip-exact"></div>
20770 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
20771 </div>
20772 <div class="stat-chip" data-raw="{{ code_lines }}">
20773 <div class="stat-chip-label">Code</div>
20774 <div class="stat-chip-val">{{ code_lines }}</div>
20775 <div class="stat-chip-exact"></div>
20776 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
20777 </div>
20778 <div class="stat-chip" data-raw="{{ comment_lines }}">
20779 <div class="stat-chip-label">Comments</div>
20780 <div class="stat-chip-val">{{ comment_lines }}</div>
20781 <div class="stat-chip-exact"></div>
20782 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
20783 </div>
20784 <div class="stat-chip" data-raw="{{ blank_lines }}">
20785 <div class="stat-chip-label">Blank</div>
20786 <div class="stat-chip-val">{{ blank_lines }}</div>
20787 <div class="stat-chip-exact"></div>
20788 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
20789 </div>
20790 <div class="stat-chip" data-raw="{{ mixed_lines }}">
20791 <div class="stat-chip-label">Mixed separate</div>
20792 <div class="stat-chip-val">{{ mixed_lines }}</div>
20793 <div class="stat-chip-exact"></div>
20794 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
20795 </div>
20796 <div class="stat-chip" data-raw="{{ functions }}">
20797 <div class="stat-chip-label">Functions</div>
20798 <div class="stat-chip-val">{{ functions }}</div>
20799 <div class="stat-chip-exact"></div>
20800 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
20801 </div>
20802 <div class="stat-chip" data-raw="{{ classes }}">
20803 <div class="stat-chip-label">Classes / Types</div>
20804 <div class="stat-chip-val">{{ classes }}</div>
20805 <div class="stat-chip-exact"></div>
20806 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
20807 </div>
20808 <div class="stat-chip" data-raw="{{ variables }}">
20809 <div class="stat-chip-label">Variables</div>
20810 <div class="stat-chip-val">{{ variables }}</div>
20811 <div class="stat-chip-exact"></div>
20812 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
20813 </div>
20814 <div class="stat-chip" data-raw="{{ imports }}">
20815 <div class="stat-chip-label">Imports</div>
20816 <div class="stat-chip-val">{{ imports }}</div>
20817 <div class="stat-chip-exact"></div>
20818 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
20819 </div>
20820 <div class="stat-chip" data-raw="{{ test_count }}">
20821 <div class="stat-chip-label">Tests</div>
20822 <div class="stat-chip-val">{{ test_count }}</div>
20823 <div class="stat-chip-exact"></div>
20824 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
20825 </div>
20826 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
20827 <div class="stat-chip-label">Code density</div>
20828 <div class="stat-chip-val stat-chip-density-val">—</div>
20829 <div class="stat-chip-exact"></div>
20830 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
20831 </div>
20832 <div class="stat-chip" data-raw="{{ files_analyzed }}">
20833 <div class="stat-chip-label">Files analyzed</div>
20834 <div class="stat-chip-val">{{ files_analyzed }}</div>
20835 <div class="stat-chip-exact"></div>
20836 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
20837 </div>
20838 {% if cyclomatic_complexity > 0 %}
20839 <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 %}>
20840 <div class="stat-chip-label">Complexity score</div>
20841 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
20842 <div class="stat-chip-exact"></div>
20843 <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>
20844 </div>
20845 {% endif %}
20846 {% if let Some(ls) = lsloc %}
20847 <div class="stat-chip" data-raw="{{ ls }}">
20848 <div class="stat-chip-label">Logical SLOC</div>
20849 <div class="stat-chip-val">{{ ls }}</div>
20850 <div class="stat-chip-exact"></div>
20851 <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>
20852 </div>
20853 {% endif %}
20854 {% if uloc > 0 %}
20855 <div class="stat-chip" data-raw="{{ uloc }}">
20856 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
20857 <div class="stat-chip-val">{{ uloc }}</div>
20858 <div class="stat-chip-exact"></div>
20859 <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>
20860 </div>
20861 {% endif %}
20862 {% if uloc > 0 && dryness_pct_str != "" %}
20863 <div class="stat-chip">
20864 <div class="stat-chip-label">DRYness</div>
20865 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
20866 <div class="stat-chip-exact"></div>
20867 <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>
20868 </div>
20869 {% endif %}
20870 {% if duplicate_group_count > 0 %}
20871 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
20872 <div class="stat-chip-label">Duplicate groups</div>
20873 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
20874 <div class="stat-chip-exact"></div>
20875 <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>
20876 </div>
20877 {% endif %}
20878 </div>
20879
20880 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
20881 <div class="compare-banner">
20882 <div class="compare-banner-body">
20883 <div class="compare-banner-top">
20884 <div class="compare-banner-meta">
20885 <span class="compare-label">Previous scan</span>
20886 <span class="compare-ts">{{ prev_ts }}</span>
20887 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
20888 {% if let Some(prev_code) = prev_run_code_lines %}
20889 <div class="compare-banner-stats" style="margin-top:4px;">
20890 <span>Code before: <strong>{{ prev_code }}</strong></span>
20891 <span class="compare-arrow">→</span>
20892 <span>Code now: <strong>{{ code_lines }}</strong></span>
20893 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
20894 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
20895 </div>
20896 {% endif %}
20897 </div>
20898 {% if delta_lines_added.is_some() %}
20899 <div class="delta-cards-inline">
20900 <div class="delta-card-inline">
20901 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
20902 <div class="delta-card-lbl">lines added</div>
20903 <div class="delta-card-tip">Code lines added since the previous scan</div>
20904 </div>
20905 <div class="delta-card-inline">
20906 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
20907 <div class="delta-card-lbl">lines removed</div>
20908 <div class="delta-card-tip">Code lines removed since the previous scan</div>
20909 </div>
20910 <div class="delta-card-inline">
20911 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
20912 <div class="delta-card-lbl">unmodified lines</div>
20913 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
20914 </div>
20915 <div class="delta-card-inline">
20916 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
20917 <div class="delta-card-lbl">files modified</div>
20918 <div class="delta-card-tip">Files with at least one line changed</div>
20919 </div>
20920 <div class="delta-card-inline">
20921 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
20922 <div class="delta-card-lbl">files added</div>
20923 <div class="delta-card-tip">New files added since the previous scan</div>
20924 </div>
20925 <div class="delta-card-inline">
20926 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
20927 <div class="delta-card-lbl">files removed</div>
20928 <div class="delta-card-tip">Files deleted since the previous scan</div>
20929 </div>
20930 <div class="delta-card-inline">
20931 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
20932 <div class="delta-card-lbl">files unchanged</div>
20933 <div class="delta-card-tip">Files with no changes since the previous scan</div>
20934 </div>
20935 </div>
20936 {% else %}
20937 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
20938 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
20939 </p>
20940 {% endif %}
20941 </div>
20942 <div class="compare-banner-actions">
20943 <div class="compare-banner-actions-left">
20944 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
20945 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
20946 </div>
20947 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
20948 </div>
20949 </div>
20950 </div>
20951 {% endif %}{% endif %}
20952
20953 <div class="action-grid">
20954 <div class="action-card">
20955 <h3>HTML report</h3>
20956 <div class="action-buttons">
20957 {% match html_url %}
20958 {% when Some with (url) %}
20959 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
20960 {% when None %}{% endmatch %}
20961 {% match html_download_url %}
20962 {% when Some with (url) %}
20963 <a class="button secondary" href="{{ url }}">Download HTML</a>
20964 {% when None %}{% endmatch %}
20965 {% match html_path %}
20966 {% when Some with (_path) %}{% when None %}{% endmatch %}
20967 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
20968 </div>
20969 </div>
20970 <div class="action-card">
20971 <h3>PDF report</h3>
20972 <div class="action-buttons">
20973 {% match pdf_url %}
20974 {% when Some with (url) %}
20975 {% if pdf_generating %}
20976 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
20977 <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>
20978 Generating PDF…
20979 </button>
20980 {% else %}
20981 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
20982 {% endif %}
20983 {% when None %}
20984 {% match html_url %}
20985 {% when Some with (_hurl) %}
20986 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
20987 <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>
20988 {% when None %}
20989 <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;">
20990 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
20991 </p>
20992 {% endmatch %}
20993 {% endmatch %}
20994 {% match pdf_download_url %}
20995 {% when Some with (url) %}
20996 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
20997 {% when None %}{% endmatch %}
20998 {% match pdf_url %}
20999 {% when Some with (_) %}
21000 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
21001 {% when None %}{% endmatch %}
21002 </div>
21003 </div>
21004 <div class="action-card">
21005 <h3>JSON result</h3>
21006 <div class="action-buttons">
21007 {% match json_url %}
21008 {% when Some with (url) %}
21009 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
21010 {% when None %}{% endmatch %}
21011 {% match json_download_url %}
21012 {% when Some with (url) %}
21013 <a class="button secondary" href="{{ url }}">Download JSON</a>
21014 {% when None %}{% endmatch %}
21015 {% match json_path %}
21016 {% when Some with (_path) %}
21017 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
21018 {% when None %}
21019 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
21020 {% endmatch %}
21021 </div>
21022 </div>
21023 <div class="action-card">
21024 <h3>Scan config</h3>
21025 <div class="action-buttons">
21026 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
21027 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
21028 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
21029 </div>
21030 </div>
21031 {% if confluence_configured %}
21032 <div class="action-card" id="confluenceCard">
21033 <h3>Confluence</h3>
21034 <div class="action-buttons">
21035 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
21036 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
21037 </div>
21038 <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>
21039 </div>
21040 {% endif %}
21041 </div>
21042 {% if confluence_configured %}
21043 <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;">
21044 <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);">
21045 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
21046 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
21047 <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;">
21048 <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>
21049 <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;">
21050 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
21051 <div style="display:flex;gap:10px;justify-content:flex-end;">
21052 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
21053 <button class="button" id="confSubmitBtn" type="button">Post</button>
21054 </div>
21055 </div>
21056 </div>
21057 {% endif %}
21058 <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;">
21059 <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);">
21060 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
21061 <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>
21062 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
21063 <div style="display:flex;gap:18px;justify-content:flex-end;">
21064 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
21065 <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>
21066 </div>
21067 </div>
21068 </div>
21069 {% if !submodule_rows.is_empty() %}
21070 <div class="submodule-panel">
21071 <div class="toolbar-row">
21072 <div>
21073 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
21074 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
21075 </div>
21076 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
21077 </div>
21078 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
21079 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
21080 <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>
21081 <thead>
21082 <tr>
21083 <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>
21084 <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>
21085 <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>
21086 <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>
21087 <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>
21088 <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>
21089 <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>
21090 <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>
21091 </tr>
21092 </thead>
21093 <tbody>
21094 {% for row in submodule_rows %}
21095 <tr>
21096 <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>
21097 <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>
21098 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
21099 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
21100 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
21101 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
21102 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
21103 <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>
21104 </tr>
21105 {% endfor %}
21106 </tbody>
21107 </table>
21108 </div>
21109 </div>
21110 {% endif %}
21111
21112 <div class="metrics-tables-stack">
21113
21114 <div class="metrics-table-wrap">
21115 <div class="metrics-table-title">Files</div>
21116 <table class="metrics-table">
21117 <thead>
21118 <tr>
21119 <th>Metric</th>
21120 <th>This Run</th>
21121 <th>Previous</th>
21122 <th>Change</th>
21123 </tr>
21124 </thead>
21125 <tbody>
21126 <tr>
21127 <td>Files analyzed</td>
21128 <td class="mt-val-large">{{ files_analyzed }}</td>
21129 <td>{{ prev_fa_str }}</td>
21130 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
21131 </tr>
21132 <tr>
21133 <td>Files skipped</td>
21134 <td>{{ files_skipped }}</td>
21135 <td>{{ prev_fs_str }}</td>
21136 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
21137 </tr>
21138 <tr>
21139 <td>Files modified</td>
21140 <td class="mt-val-na">—</td>
21141 <td class="mt-val-na">—</td>
21142 <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21143 </tr>
21144 <tr>
21145 <td>Files unchanged</td>
21146 <td class="mt-val-na">—</td>
21147 <td class="mt-val-na">—</td>
21148 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21149 </tr>
21150 </tbody>
21151 </table>
21152 </div>
21153
21154 <div class="metrics-table-wrap">
21155 <div class="metrics-table-title">Line Counts</div>
21156 <table class="metrics-table">
21157 <thead>
21158 <tr>
21159 <th>Metric</th>
21160 <th>This Run</th>
21161 <th>Previous</th>
21162 <th>Change</th>
21163 </tr>
21164 </thead>
21165 <tbody>
21166 <tr>
21167 <td>Physical lines</td>
21168 <td class="mt-val-large">{{ physical_lines }}</td>
21169 <td>{{ prev_pl_str }}</td>
21170 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
21171 </tr>
21172 <tr>
21173 <td>Code lines</td>
21174 <td class="mt-val-large">{{ code_lines }}</td>
21175 <td>{{ prev_cl_str }}</td>
21176 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
21177 </tr>
21178 <tr>
21179 <td>Comment lines</td>
21180 <td>{{ comment_lines }}</td>
21181 <td>{{ prev_cml_str }}</td>
21182 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
21183 </tr>
21184 <tr>
21185 <td>Blank lines</td>
21186 <td>{{ blank_lines }}</td>
21187 <td>{{ prev_bl_str }}</td>
21188 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
21189 </tr>
21190 <tr>
21191 <td>Mixed (separate)</td>
21192 <td>{{ mixed_lines }}</td>
21193 <td class="mt-val-na">—</td>
21194 <td class="mt-val-na">—</td>
21195 </tr>
21196 </tbody>
21197 </table>
21198 </div>
21199
21200 <div class="metrics-tables-lower">
21201 <div class="metrics-table-wrap">
21202 <div class="metrics-table-title">Code Structure</div>
21203 <table class="metrics-table">
21204 <thead>
21205 <tr>
21206 <th>Metric</th>
21207 <th>This Run</th>
21208 </tr>
21209 </thead>
21210 <tbody>
21211 <tr>
21212 <td>Functions</td>
21213 <td>{{ functions }}</td>
21214 </tr>
21215 <tr>
21216 <td>Classes / Types</td>
21217 <td>{{ classes }}</td>
21218 </tr>
21219 <tr>
21220 <td>Variables</td>
21221 <td>{{ variables }}</td>
21222 </tr>
21223 <tr>
21224 <td>Imports</td>
21225 <td>{{ imports }}</td>
21226 </tr>
21227 </tbody>
21228 </table>
21229 </div>
21230
21231 <div class="metrics-table-wrap">
21232 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
21233 <table class="metrics-table">
21234 <thead>
21235 <tr>
21236 <th>Metric</th>
21237 <th>Change</th>
21238 </tr>
21239 </thead>
21240 <tbody>
21241 <tr>
21242 <td>Lines added</td>
21243 <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21244 </tr>
21245 <tr>
21246 <td>Lines removed</td>
21247 <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">−{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21248 </tr>
21249 <tr>
21250 <td>Lines modified (net)</td>
21251 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
21252 </tr>
21253 <tr>
21254 <td>Lines unmodified</td>
21255 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21256 </tr>
21257 </tbody>
21258 </table>
21259 </div>
21260 </div>
21261
21262 </div>
21263
21264 <div class="path-list">
21265 <div class="path-item">
21266 <div class="path-item-label">Project path</div>
21267 <code>{{ project_path }}</code>
21268 </div>
21269 <div class="path-item">
21270 <div class="path-item-label">Git branch</div>
21271 {% if let Some(branch) = git_branch %}
21272 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
21273 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
21274 {% else %}
21275 <code style="color:var(--muted)">—</code>
21276 {% endif %}
21277 </div>
21278 <div class="path-item">
21279 <div class="path-item-label">Output folder</div>
21280 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
21281 </div>
21282 <div class="path-item">
21283 <div class="path-item-label">Run ID</div>
21284 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
21285 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
21286 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
21287 </div>
21288 </div>
21289 </div>
21290 </section>
21291
21292 {% if has_cocomo %}
21293 <div class="cocomo-box" style="margin-top:24px;">
21294 <div class="cocomo-box-head">
21295 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
21296 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
21297 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
21298 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
21299 </span>
21300 </div>
21301 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
21302 <div class="stat-chip">
21303 <div class="stat-chip-label">Person-months</div>
21304 <div class="stat-chip-val">{{ cocomo_effort_str }}</div>
21305 <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>
21306 </div>
21307 <div class="stat-chip">
21308 <div class="stat-chip-label">Schedule (months)</div>
21309 <div class="stat-chip-val">{{ cocomo_duration_str }}</div>
21310 <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>
21311 </div>
21312 <div class="stat-chip">
21313 <div class="stat-chip-label">Avg. Team Size</div>
21314 <div class="stat-chip-val">{{ cocomo_staff_str }}</div>
21315 <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>
21316 </div>
21317 <div class="stat-chip">
21318 <div class="stat-chip-label">Input KSLOC</div>
21319 <div class="stat-chip-val">{{ cocomo_ksloc_str }}K</div>
21320 <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>
21321 </div>
21322 </div>
21323 <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>
21324 </div>
21325 {% endif %}
21326
21327 <div class="section-pair">
21328 <section class="panel">
21329 <div class="toolbar-row">
21330 <div>
21331 <h2>Language breakdown</h2>
21332 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
21333 </div>
21334 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21335 </div>
21336 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
21337 </section>
21338
21339 <section class="panel r-chart-section">
21340 <div class="toolbar-row" style="margin-bottom:16px;">
21341 <div>
21342 <h2>Visualizations</h2>
21343 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
21344 </div>
21345 </div>
21346
21347 <div class="r-viz-grid">
21348 <div class="r-viz-card">
21349 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21350 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
21351 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21352 </div>
21353 <div class="r-chart-tab-bar">
21354 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
21355 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
21356 </div>
21357 <div class="r-chart-container" id="r-composition-chart"></div>
21358 </div>
21359 <div class="r-viz-card">
21360 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21361 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
21362 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21363 </div>
21364 <div class="r-chart-container" id="r-scatter-chart"></div>
21365 </div>
21366 {% if has_semantic_data %}
21367 <div class="r-viz-card">
21368 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21369 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
21370 <select class="r-chart-select" id="r-semantic-metric">
21371 <option value="functions">Functions</option>
21372 <option value="classes">Classes</option>
21373 <option value="variables">Variables</option>
21374 <option value="imports">Imports</option>
21375 <option value="tests">Tests</option>
21376 </select>
21377 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21378 </div>
21379 <div class="r-chart-container" id="r-semantic-chart"></div>
21380 </div>
21381 {% endif %}
21382 <div class="r-viz-card">
21383 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21384 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
21385 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21386 </div>
21387 <div class="r-chart-container" id="r-density-chart"></div>
21388 </div>
21389 <div class="r-viz-card">
21390 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21391 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
21392 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21393 </div>
21394 <div class="r-chart-container" id="r-avglines-chart"></div>
21395 </div>
21396 <div class="r-viz-card">
21397 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
21398 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
21399 <select class="r-chart-select" id="r-sub-metric">
21400 <option value="code">Code Lines</option>
21401 <option value="comment">Comments</option>
21402 <option value="blank">Blank Lines</option>
21403 <option value="physical">Physical Lines</option>
21404 <option value="files">Files</option>
21405 </select>
21406 <select class="r-chart-select" id="r-sub-sort">
21407 <option value="desc">Value ↓</option>
21408 <option value="asc">Value ↑</option>
21409 <option value="name">Name A→Z</option>
21410 </select>
21411 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21412 </div>
21413 <div class="r-chart-container" id="r-submodule-chart"></div>
21414 </div>
21415 </div>
21416
21417 </section>
21418 </div>
21419
21420 </div>
21421
21422 <div id="r-tt" aria-hidden="true"></div>
21423
21424 <script nonce="{{ csp_nonce }}">
21425 (function () {
21426 var body = document.body;
21427 var themeToggle = document.getElementById('theme-toggle');
21428 var storageKey = 'oxide-sloc-theme';
21429
21430 function applyTheme(theme) {
21431 body.classList.toggle('dark-theme', theme === 'dark');
21432 }
21433
21434 function loadSavedTheme() {
21435 try {
21436 var saved = localStorage.getItem(storageKey);
21437 if (saved === 'dark' || saved === 'light') {
21438 applyTheme(saved);
21439 }
21440 } catch (e) {}
21441 }
21442
21443 if (themeToggle) {
21444 themeToggle.addEventListener('click', function () {
21445 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
21446 applyTheme(nextTheme);
21447 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
21448 });
21449 }
21450
21451 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
21452 button.addEventListener('click', function () {
21453 var value = button.getAttribute('data-copy-value') || '';
21454 if (!value) return;
21455 var originalText = button.textContent;
21456 function flashSuccess() {
21457 button.textContent = 'Copied!';
21458 setTimeout(function () { button.textContent = originalText; }, 1800);
21459 }
21460 function flashFail() {
21461 button.textContent = 'Copy failed';
21462 setTimeout(function () { button.textContent = originalText; }, 2000);
21463 }
21464 if (navigator.clipboard && navigator.clipboard.writeText) {
21465 navigator.clipboard.writeText(value).then(flashSuccess, function () {
21466 fallbackCopy(value, flashSuccess, flashFail);
21467 });
21468 } else {
21469 fallbackCopy(value, flashSuccess, flashFail);
21470 }
21471 });
21472 });
21473 function fallbackCopy(text, onSuccess, onFail) {
21474 try {
21475 var ta = document.createElement('textarea');
21476 ta.value = text;
21477 ta.style.position = 'fixed';
21478 ta.style.top = '-9999px';
21479 ta.style.left = '-9999px';
21480 document.body.appendChild(ta);
21481 ta.focus();
21482 ta.select();
21483 var ok = document.execCommand('copy');
21484 document.body.removeChild(ta);
21485 if (ok) { onSuccess(); } else { onFail(); }
21486 } catch (e) { onFail(); }
21487 }
21488
21489 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21490 btn.addEventListener('click', function () {
21491 var folder = btn.getAttribute('data-folder') || '';
21492 if (!folder) return;
21493 var orig = btn.textContent;
21494 fetch('/open-path?path=' + encodeURIComponent(folder))
21495 .then(function (r) { return r.json(); })
21496 .then(function (d) {
21497 if (d && d.server_mode_disabled) {
21498 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21499 } else if (d && d.ok) {
21500 btn.textContent = 'Opened!';
21501 setTimeout(function () { btn.textContent = orig; }, 1800);
21502 }
21503 })
21504 .catch(function () {
21505 btn.textContent = 'Failed';
21506 setTimeout(function () { btn.textContent = orig; }, 2000);
21507 });
21508 });
21509 });
21510
21511 loadSavedTheme();
21512
21513 // ── Compact number formatting for stat chips ──────────────────────────
21514 (function(){
21515 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();}
21516 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
21517 var raw=parseInt(chip.getAttribute('data-raw'),10);
21518 if(isNaN(raw))return;
21519 var valEl=chip.querySelector('.stat-chip-val');
21520 if(valEl)valEl.textContent=fmt(raw);
21521 var exactEl=chip.querySelector('.stat-chip-exact');
21522 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
21523 });
21524 // Code density chip
21525 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
21526 var code=parseInt(chip.getAttribute('data-code'),10);
21527 var phys=parseInt(chip.getAttribute('data-physical'),10);
21528 if(isNaN(code)||isNaN(phys)||phys===0)return;
21529 var pct=(code/phys*100).toFixed(1)+'%';
21530 var valEl=chip.querySelector('.stat-chip-val');
21531 if(valEl)valEl.textContent=pct;
21532 });
21533 // Populate author handle from data-author attribute
21534 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
21535 var author=chip.getAttribute('data-author');
21536 var el=chip.querySelector('.author-handle');
21537 if(el)el.textContent='/'+author.replace(/\s+/g,'');
21538 });
21539 // Click-to-copy on run-id-chip elements
21540 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
21541 chip.addEventListener('click',function(){
21542 var val=chip.getAttribute('data-copy');
21543 if(!val)return;
21544 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
21545 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);}
21546 chip.classList.add('chip-copied-flash');
21547 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
21548 });
21549 });
21550 })();
21551
21552 // ── Shared tooltip for all result-page charts ─────────────────────────
21553 var rTT=(function(){
21554 var el=document.getElementById('r-tt');
21555 if(!el)return{s:function(){},h:function(){},m:function(){}};
21556 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
21557 function hide(){el.style.display='none';}
21558 function move(e){
21559 var x=e.clientX+16,y=e.clientY-12;
21560 var r=el.getBoundingClientRect();
21561 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
21562 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
21563 el.style.left=x+'px';el.style.top=y+'px';
21564 }
21565 return{s:show,h:hide,m:move};
21566 })();
21567 window.rTT=rTT;
21568
21569 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
21570 (function(){
21571 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21572 document.addEventListener('mouseover',function(e){
21573 var t=e.target;
21574 while(t&&t.getAttribute){
21575 var l=t.getAttribute('data-ttl');
21576 if(l!==null){
21577 var v=t.getAttribute('data-ttv')||'';
21578 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
21579 return;
21580 }
21581 t=t.parentNode;
21582 }
21583 });
21584 document.addEventListener('mouseout',function(e){
21585 var t=e.target;
21586 while(t&&t.getAttribute){
21587 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
21588 t=t.parentNode;
21589 }
21590 });
21591 document.addEventListener('mousemove',function(e){
21592 var el=document.getElementById('r-tt');
21593 if(el&&el.style.display!=='none')rTT.m(e);
21594 });
21595 window.addEventListener('blur',function(){rTT.h();});
21596 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
21597 })();
21598
21599 // ── Language overview charts ───────────────────────────────────────────
21600 (function(){
21601 var D={{ lang_chart_json|safe }};
21602 if(!D||!D.length)return;
21603 var el=document.getElementById('result-lang-charts');
21604 if(!el)return;
21605 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21606 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
21607 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21608 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();}
21609 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21610 function px(n){return Math.round(n);}
21611 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+'"';}
21612 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
21613
21614 // Donut chart — height matches the stacked-bar chart so both panels align
21615 var rHb_d=28;
21616 var DH=Math.max(220,D.length*rHb_d+32);
21617 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
21618 var legX=204,DW=360;
21619 var legCount=D.length;
21620 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
21621 var legYStart=Math.round((DH-legCount*legSpacing)/2);
21622 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">';
21623 if(D.length===1){
21624 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
21625 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+'"/>';
21626 } else {
21627 var ang=-Math.PI/2;
21628 D.forEach(function(d,i){
21629 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21630 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
21631 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
21632 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
21633 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
21634 var pct=Math.round(d.code/tot*100);
21635 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"/>';
21636 ang+=sw;
21637 });
21638 }
21639 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
21640 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
21641 D.forEach(function(d,i){
21642 var ly=legYStart+i*legSpacing;
21643 var pctL=Math.round(d.code/tot*100);
21644 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
21645 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
21646 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
21647 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
21648 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
21649 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
21650 ds+='</g>';
21651 });
21652 ds+='</svg>';
21653
21654 // Horizontal stacked-bar chart — fills container width
21655 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
21656 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
21657 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">';
21658 D.forEach(function(d,i){
21659 var y=6+i*rHb,x=LW;
21660 var phys=d.physical||d.code+d.comments+d.blanks;
21661 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
21662 bs+='<g class="lang-bar-row">';
21663 bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
21664 bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
21665 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="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
21666 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="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
21667 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="'+bH+'" fill="'+GY+'" rx="0"/>';
21668 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
21669 bs+='</g>';
21670 });
21671 var ly=SH-14;
21672 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
21673 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
21674 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
21675 var totAll=totC+totCm+totBl||1;
21676 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
21677 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
21678 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
21679 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
21680 bs+='<g data-kind="code" style="cursor:pointer;">'
21681 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
21682 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
21683 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
21684 +'</g>';
21685 bs+='<g data-kind="comment" style="cursor:pointer;">'
21686 +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
21687 +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
21688 +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
21689 +'</g>';
21690 bs+='<g data-kind="blank" style="cursor:pointer;">'
21691 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
21692 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
21693 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
21694 +'</g>';
21695 bs+='</svg>';
21696 el.innerHTML='<div class="r-lang-overview">'+
21697 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
21698 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
21699 '</div>';
21700 function wireDonutLegend(svg){
21701 if(!svg)return;
21702 var paths=svg.querySelectorAll('path[data-lang]');
21703 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';}}}
21704 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
21705 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;}});
21706 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
21707 }
21708 function wireMixLegend(svg){
21709 if(!svg)return;
21710 var legGs=svg.querySelectorAll('g[data-kind]');
21711 var allRects=svg.querySelectorAll('rect[data-kind]');
21712 if(!legGs.length)return;
21713 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';}}
21714 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='';}}
21715 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]);}
21716 }
21717 wireDonutLegend(el.querySelector('svg'));
21718 wireMixLegend(el.querySelectorAll('svg')[1]);
21719
21720 // ── Language breakdown Full View expand ─────────────────────────────────
21721 var langOvBtn=document.getElementById('result-lang-overview-expand');
21722 if(langOvBtn){langOvBtn.addEventListener('click',function(){
21723 var src=document.getElementById('result-lang-charts');
21724 if(!src)return;
21725 var overlay=document.createElement('div');
21726 overlay.className='r-chart-modal-overlay';
21727 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 — Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
21728 document.body.appendChild(overlay);
21729 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21730 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21731 var wrap=document.getElementById('result-lang-overview-modal-wrap');
21732 if(wrap){
21733 wrap.innerHTML=src.innerHTML;
21734 var svgs=wrap.querySelectorAll('svg');
21735 for(var i=0;i<svgs.length;i++){
21736 svgs[i].removeAttribute('width');
21737 svgs[i].removeAttribute('height');
21738 svgs[i].style.cssText='display:block;width:100%;height:auto;';
21739 }
21740 var ov=wrap.querySelector('.r-lang-overview');
21741 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
21742 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
21743 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
21744 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
21745 wireDonutLegend(wrap.querySelector('svg'));
21746 wireMixLegend(wrap.querySelectorAll('svg')[1]);
21747 requestAnimationFrame(function(){
21748 var ss=wrap.querySelectorAll('svg');
21749 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%;';}}
21750 });
21751 }
21752 });}
21753 })();
21754
21755 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
21756 (function(){
21757 var LANG_D={{ lang_chart_json|safe }};
21758 var SCAT_D={{ scatter_chart_json|safe }};
21759 var SEM_D={{ semantic_chart_json|safe }};
21760 var SUB_D={{ submodule_chart_json|safe }};
21761 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
21762 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21763 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();}
21764 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21765 function px(n){return Math.round(n);}
21766 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+'"';}
21767
21768 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
21769 function renderCompositionInEl(el,mode,shOvr){
21770 if(!el||!LANG_D||!LANG_D.length)return;
21771 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21772 var LW=110,SH=shOvr||224;
21773 var svgW=Math.max(320,el.offsetWidth||480);
21774 var BW=Math.max(120,svgW-LW-80);
21775 var legendH=24,topPad=4;
21776 var n=LANG_D.length||1;
21777 var rowTotal=Math.floor((SH-legendH-topPad)/n);
21778 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
21779 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">';
21780 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
21781 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
21782 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
21783 var totAll2=totC2+totCm2+totBl2||1;
21784 if(mode==='pct'){
21785 LANG_D.forEach(function(d,i){
21786 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
21787 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
21788 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21789 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>';
21790 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+'"/>';x+=cW;
21791 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+'"/>';x+=cmW;
21792 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+'"/>';
21793 var pct=Math.round((d.code||0)/tot2*100);
21794 s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+pct+'%</text>';
21795 });
21796 } else {
21797 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
21798 LANG_D.forEach(function(d,i){
21799 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
21800 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21801 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>';
21802 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+'"/>';x+=cW;
21803 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+'"/>';x+=cmW;
21804 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+'"/>';
21805 s+='<text x="'+(LW+cW+cmW+blW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
21806 });
21807 }
21808 var ly=SH-legendH+4;
21809 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
21810 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
21811 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
21812 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
21813 s+='<g data-kind="code" style="cursor:pointer;">'
21814 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
21815 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
21816 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
21817 +'</g>';
21818 s+='<g data-kind="comment" style="cursor:pointer;">'
21819 +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
21820 +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
21821 +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
21822 +'</g>';
21823 s+='<g data-kind="blank" style="cursor:pointer;">'
21824 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
21825 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
21826 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
21827 +'</g>';
21828 s+='</svg>';
21829 el.innerHTML=s;
21830 wireMixLegendEl(el);
21831 }
21832 function wireMixLegendEl(container){
21833 var svg=container&&container.querySelector('svg');
21834 if(!svg)return;
21835 var legGs=svg.querySelectorAll('g[data-kind]');
21836 var allRects=svg.querySelectorAll('rect[data-kind]');
21837 if(!legGs.length)return;
21838 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';}}
21839 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='';}}
21840 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]);}
21841 }
21842 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
21843 renderComposition('abs');
21844 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
21845 btn.addEventListener('click',function(){
21846 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
21847 btn.classList.add('active');
21848 renderComposition(btn.getAttribute('data-rcomp'));
21849 });
21850 });
21851
21852 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
21853 function renderScatterInEl(el,hOvr){
21854 if(!el||!SCAT_D||!SCAT_D.length)return;
21855 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
21856 var W=Math.max(320,el.offsetWidth||480);
21857 var cW=W-PL-PR,cH=H-PT-PB;
21858 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
21859 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
21860 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
21861 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">';
21862 [0,0.25,0.5,0.75,1].forEach(function(t){
21863 var y=PT+cH*(1-t);
21864 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21865 if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';
21866 });
21867 [0,0.25,0.5,0.75,1].forEach(function(t){
21868 var x=PL+cW*t;
21869 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21870 if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';
21871 });
21872 SCAT_D.forEach(function(d,i){
21873 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
21874 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
21875 s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
21876 if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';
21877 });
21878 s+='<text x="'+(PL+cW/2)+'" y="'+(H-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7">Files</text>';
21879 s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
21880 s+='</svg>';
21881 el.innerHTML=s;
21882 }
21883 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
21884
21885 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
21886 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
21887 // the old vertical column layout on wide containers.
21888 function renderSemanticInEl(el,key,sh){
21889 if(!el||!SEM_D||!SEM_D.length)return;
21890 var n2=SEM_D.length||1;
21891 var LW=112,SH=sh||Math.max(180,n2*28+26);
21892 var svgW=Math.max(320,el.offsetWidth||480);
21893 var BW=Math.max(120,svgW-LW-80);
21894 var topPad=4,botPad=14;
21895 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
21896 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
21897 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
21898 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">';
21899 SEM_D.forEach(function(d,i){
21900 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
21901 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>';
21902 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"/>';
21903 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>';
21904 });
21905 s+='</svg>';
21906 el.innerHTML=s;
21907 }
21908 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
21909 var semSel=document.getElementById('r-semantic-metric');
21910 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
21911 var semExpand=document.getElementById('r-semantic-expand');
21912 if(semExpand){
21913 semExpand.addEventListener('click',function(){
21914 var key=semSel?semSel.value:'functions';
21915 var n=SEM_D.length||1;
21916 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
21917 var modalH=Math.min(Math.max(360,n*38+60),maxH);
21918 var overlay=document.createElement('div');
21919 overlay.className='r-chart-modal-overlay';
21920 var optHtml=
21921 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
21922 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
21923 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
21924 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
21925 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
21926 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 — Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
21927 document.body.appendChild(overlay);
21928 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21929 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21930 var modalEl=document.getElementById('r-sem-modal-chart');
21931 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
21932 var modalSel=document.getElementById('r-sem-modal-metric');
21933 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
21934 });
21935 }
21936
21937 // ── Expand buttons: re-render charts at large size inside modal ──────────
21938 (function(){
21939 function makeExpandModal(title,mH,subtitle,ctrlHtml){
21940 var overlay=document.createElement('div');
21941 overlay.className='r-chart-modal-overlay';
21942 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
21943 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
21944 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>';
21945 document.body.appendChild(overlay);
21946 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21947 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21948 return overlay.querySelector('.r-expand-modal-chart');
21949 }
21950 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
21951 var compExpandBtn=document.getElementById('r-composition-expand');
21952 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
21953 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
21954 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21955 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
21956 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
21957 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
21958 if(wrap){
21959 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
21960 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
21961 btn.addEventListener('click',function(){
21962 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
21963 btn.classList.add('active');
21964 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
21965 });
21966 });
21967 }
21968 });}
21969 var scatExpandBtn=document.getElementById('r-scatter-expand');
21970 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
21971 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
21972 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
21973 });}
21974 var densExpandBtn=document.getElementById('r-density-expand');
21975 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
21976 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21977 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
21978 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
21979 });}
21980 var avgExpandBtn=document.getElementById('r-avglines-expand');
21981 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
21982 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
21983 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
21984 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
21985 });}
21986 var subExpandBtn=document.getElementById('r-submodule-expand');
21987 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
21988 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
21989 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
21990 var metCtrl=
21991 '<select class="r-chart-select" id="r-sub-modal-metric">'
21992 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
21993 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
21994 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
21995 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
21996 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
21997 +'</select>';
21998 var sortCtrl=
21999 '<select class="r-chart-select" id="r-sub-modal-sort">'
22000 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
22001 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
22002 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
22003 +'</select>';
22004 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
22005 if(wrap){
22006 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
22007 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
22008 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
22009 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
22010 if(mSub)mSub.addEventListener('change',reRenderSub);
22011 if(mSort)mSort.addEventListener('change',reRenderSub);
22012 }
22013 });}
22014 })();
22015
22016 // ── Comment Density: comments / (code + comments) per language ───────────
22017 function renderDensityInEl(el,shOvr){
22018 if(!el||!LANG_D||!LANG_D.length)return;
22019 var n=LANG_D.length||1;
22020 var LW=112,SH=shOvr||Math.max(180,n*28+26);
22021 var svgW=Math.max(320,el.offsetWidth||480);
22022 var BW=Math.max(120,svgW-LW-80);
22023 var topPad=4,botPad=26;
22024 var rowTotal=Math.floor((SH-topPad-botPad)/n);
22025 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22026 var densities=LANG_D.map(function(d){
22027 var sig=(d.code||0)+(d.comments||0);
22028 return sig>0?(d.comments||0)/sig:0;
22029 });
22030 var maxDen=Math.max.apply(null,densities)||1;
22031 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">';
22032 LANG_D.forEach(function(d,i){
22033 var den=densities[i],bw=den/maxDen*BW;
22034 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22035 var pct=Math.round(den*100);
22036 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>';
22037 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"/>';
22038 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22039 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>';
22040 });
22041 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>';
22042 s+='</svg>';
22043 el.innerHTML=s;
22044 }
22045 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
22046 renderDensity();
22047
22048 // ── Avg Lines per File: code / files per language ─────────────────────
22049 function renderAvgLinesInEl(el,shOvr){
22050 if(!el||!LANG_D||!LANG_D.length)return;
22051 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
22052 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
22053 var n=data.length||1;
22054 var LW=112,SH=shOvr||Math.max(180,n*28+26);
22055 var svgW=Math.max(320,el.offsetWidth||480);
22056 var BW=Math.max(120,svgW-LW-80);
22057 var topPad=4,botPad=26;
22058 var rowTotal=Math.floor((SH-topPad-botPad)/n);
22059 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22060 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
22061 var maxAvg=Math.max.apply(null,avgs)||1;
22062 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">';
22063 data.forEach(function(d,i){
22064 var avg=avgs[i],bw=avg/maxAvg*BW;
22065 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22066 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>';
22067 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
22068 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22069 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>';
22070 });
22071 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>';
22072 s+='</svg>';
22073 el.innerHTML=s;
22074 }
22075 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
22076 renderAvgLines();
22077
22078 // ── Repository Overview: overall row + per-submodule rows ────────────
22079 function renderSubmoduleInEl(el,key,sort,shOvr){
22080 if(!el)return;
22081 var overall={
22082 name:'Overall',
22083 code:{{ code_lines }},
22084 comment:{{ comment_lines }},
22085 blank:{{ blank_lines }},
22086 physical:{{ physical_lines }},
22087 files:{{ files_analyzed }},
22088 isOverall:true
22089 };
22090 var subs=SUB_D.slice();
22091 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
22092 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
22093 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
22094 var data=[overall].concat(subs);
22095 var rowH=32,bH=22,sepH=subs.length>0?14:0;
22096 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
22097 var svgW=Math.max(320,el.offsetWidth||480);
22098 var LW=116,BW=Math.max(200,svgW-LW-54);
22099 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
22100 var OVERALL_COL='#6b7280';
22101 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">';
22102 var yOff=4;
22103 data.forEach(function(d,i){
22104 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
22105 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
22106 var label=d.name||d.path||'?';
22107 s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
22108 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
22109 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22110 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
22111 yOff+=rowH;
22112 if(d.isOverall&&subs.length>0){
22113 yOff+=sepH;
22114 }
22115 });
22116 s+='</svg>';
22117 el.innerHTML=s;
22118 }
22119 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
22120 var subSel=document.getElementById('r-sub-metric');
22121 var sortSel=document.getElementById('r-sub-sort');
22122 renderSubmodule('code','desc');
22123 if(subSel){
22124 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
22125 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
22126 }
22127
22128 // Equalise heights within each chart row: if one chart in a grid row is taller
22129 // than its neighbour, re-render the shorter one at the taller height so bars fill
22130 // the available vertical space instead of leaving a gap.
22131 function syncRowHeights(){
22132 var avgEl=document.getElementById('r-avglines-chart');
22133 var subEl=document.getElementById('r-submodule-chart');
22134 if(avgEl&&subEl){
22135 var avgSvg=avgEl.querySelector('svg');
22136 var subSvg=subEl.querySelector('svg');
22137 if(avgSvg&&subSvg){
22138 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
22139 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
22140 var key=subSel?subSel.value||'code':'code';
22141 var sort=sortSel?sortSel.value:'desc';
22142 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
22143 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
22144 }
22145 }
22146 var semEl=document.getElementById('r-semantic-chart');
22147 var denEl=document.getElementById('r-density-chart');
22148 if(semEl&&denEl){
22149 var semSvg=semEl.querySelector('svg');
22150 var denSvg=denEl.querySelector('svg');
22151 if(semSvg&&denSvg){
22152 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
22153 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
22154 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
22155 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
22156 }
22157 }
22158 }
22159 syncRowHeights();
22160
22161 // Re-render all SVG charts when the window is resized so bars fill the card.
22162 var _rResizeTimer;
22163 window.addEventListener('resize',function(){
22164 clearTimeout(_rResizeTimer);
22165 _rResizeTimer=setTimeout(function(){
22166 var rcompBtn=document.querySelector('[data-rcomp].active');
22167 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
22168 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
22169 if(semSel)renderSemantic(semSel.value||'functions');
22170 renderDensity();
22171 renderAvgLines();
22172 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
22173 syncRowHeights();
22174 },120);
22175 });
22176 })();
22177
22178 (function randomizeWatermarks() {
22179 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22180 if (!wms.length) return;
22181 var placed = [];
22182 function tooClose(top, left) {
22183 for (var i = 0; i < placed.length; i++) {
22184 var dt = Math.abs(placed[i][0] - top);
22185 var dl = Math.abs(placed[i][1] - left);
22186 if (dt < 20 && dl < 18) return true;
22187 }
22188 return false;
22189 }
22190 function pick(leftBand) {
22191 for (var attempt = 0; attempt < 50; attempt++) {
22192 var top = Math.random() * 85 + 5;
22193 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22194 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22195 }
22196 var top = Math.random() * 85 + 5;
22197 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22198 placed.push([top, left]);
22199 return [top, left];
22200 }
22201 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
22202 var half = Math.floor(wms.length / 2);
22203 wms.forEach(function (img, i) {
22204 var pos = pick(i < half);
22205 var size = Math.floor(Math.random() * 100 + 160);
22206 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
22207 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
22208 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;
22209 });
22210 })();
22211
22212 (function spawnCodeParticles() {
22213 var container = document.getElementById('code-particles');
22214 if (!container) return;
22215 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'];
22216 for (var i = 0; i < 38; i++) {
22217 (function(idx) {
22218 var el = document.createElement('span');
22219 el.className = 'code-particle';
22220 el.textContent = snippets[idx % snippets.length];
22221 var left = Math.random() * 94 + 2;
22222 var top = Math.random() * 88 + 6;
22223 var dur = (Math.random() * 10 + 9).toFixed(1);
22224 var delay = (Math.random() * 18).toFixed(1);
22225 var rot = (Math.random() * 26 - 13).toFixed(1);
22226 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22227 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';
22228 container.appendChild(el);
22229 })(i);
22230 }
22231 })();
22232
22233 {% if pdf_generating %}
22234 // Poll for PDF readiness and swap the disabled button to a live link once done.
22235 (function() {
22236 var openBtn = document.getElementById('pdf-open-btn');
22237 var dlBtn = document.getElementById('pdf-download-btn');
22238 function checkPdf() {
22239 fetch('/api/runs/{{ run_id }}/pdf-status')
22240 .then(function(r) { return r.json(); })
22241 .then(function(d) {
22242 if (d.ready) {
22243 if (openBtn) {
22244 var a = document.createElement('a');
22245 a.className = 'button';
22246 a.id = 'pdf-open-btn';
22247 a.href = '/runs/pdf/{{ run_id }}';
22248 a.target = '_blank';
22249 a.rel = 'noopener';
22250 a.textContent = 'Open PDF';
22251 openBtn.replaceWith(a);
22252 }
22253 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
22254 } else {
22255 setTimeout(checkPdf, 3000);
22256 }
22257 })
22258 .catch(function() { setTimeout(checkPdf, 5000); });
22259 }
22260 setTimeout(checkPdf, 3000);
22261 })();
22262 {% endif %}
22263
22264 })();
22265 </script>
22266 <script nonce="{{ csp_nonce }}">
22267 (function(){
22268 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'}];
22269 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);});}
22270 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22271 function init(){
22272 var btn=document.getElementById('settings-btn');if(!btn)return;
22273 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22274 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>';
22275 document.body.appendChild(m);
22276 var g=document.getElementById('scheme-grid');
22277 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);});
22278 var cl=document.getElementById('settings-close');
22279 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);
22280 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');});
22281 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22282 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22283 }
22284 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22285 }());
22286 </script>
22287 <footer class="site-footer">
22288 local code analysis - metrics, history and reports
22289 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22290 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22291 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22292 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22293 · <a href="/api-docs" rel="noopener">REST API</a>
22294 </footer>
22295 {% if confluence_configured %}
22296 <script nonce="{{ csp_nonce }}">
22297 (function() {
22298 var postBtn = document.getElementById('postConfluenceBtn');
22299 var copyBtn = document.getElementById('copyWikiBtn');
22300 var modal = document.getElementById('confluenceModal');
22301 if (!postBtn || !modal) return;
22302
22303 postBtn.addEventListener('click', function() {
22304 document.getElementById('confStatus').style.display = 'none';
22305 modal.style.display = 'flex';
22306 });
22307 document.getElementById('confCancelBtn').addEventListener('click', function() {
22308 modal.style.display = 'none';
22309 });
22310 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22311
22312 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
22313 var btn = this;
22314 btn.disabled = true;
22315 var status = document.getElementById('confStatus');
22316 status.style.display = 'block';
22317 status.style.background = '#dbeafe';
22318 status.style.color = '#1e40af';
22319 status.textContent = 'Posting to Confluence…';
22320 var resp = await fetch('/api/confluence/post', {
22321 method: 'POST',
22322 headers: { 'Content-Type': 'application/json' },
22323 body: JSON.stringify({
22324 run_id: '{{ run_id }}',
22325 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
22326 report_url: document.getElementById('confReportUrl').value.trim() || null
22327 })
22328 });
22329 var data = await resp.json();
22330 if (data.ok) {
22331 status.style.background = '#dcfce7'; status.style.color = '#166534';
22332 status.textContent = 'Posted! Page ID: ' + data.page_id;
22333 } else {
22334 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22335 status.textContent = 'Error: ' + (data.error || 'Unknown error');
22336 }
22337 btn.disabled = false;
22338 });
22339
22340 if (copyBtn) {
22341 copyBtn.addEventListener('click', async function() {
22342 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
22343 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
22344 var text = await resp.text();
22345 try {
22346 await navigator.clipboard.writeText(text);
22347 var orig = copyBtn.textContent;
22348 copyBtn.textContent = 'Copied!';
22349 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
22350 } catch(e) {
22351 alert('Clipboard write failed — check browser permissions.');
22352 }
22353 });
22354 }
22355 })();
22356 </script>
22357 {% endif %}
22358 <script nonce="{{ csp_nonce }}">
22359 (function() {
22360 var deleteBtn = document.getElementById('delete-run-btn');
22361 var modal = document.getElementById('delete-run-modal');
22362 var cancelBtn = document.getElementById('delete-run-cancel');
22363 var confirmBtn= document.getElementById('delete-run-confirm');
22364 if (!deleteBtn || !modal) return;
22365 deleteBtn.addEventListener('click', function() {
22366 document.getElementById('delete-run-status').style.display = 'none';
22367 modal.style.display = 'flex';
22368 });
22369 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
22370 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22371 confirmBtn.addEventListener('click', async function() {
22372 confirmBtn.disabled = true;
22373 cancelBtn.disabled = true;
22374 var status = document.getElementById('delete-run-status');
22375 status.style.display = 'block';
22376 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
22377 status.textContent = 'Deleting…';
22378 try {
22379 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
22380 if (resp.status === 204 || resp.ok) {
22381 status.style.background = '#dcfce7'; status.style.color = '#166534';
22382 status.textContent = 'Deleted. Redirecting…';
22383 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
22384 } else {
22385 var d = await resp.json().catch(function(){return {};});
22386 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22387 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
22388 confirmBtn.disabled = false;
22389 cancelBtn.disabled = false;
22390 }
22391 } catch (e) {
22392 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22393 status.textContent = 'Network error: ' + String(e);
22394 confirmBtn.disabled = false;
22395 cancelBtn.disabled = false;
22396 }
22397 });
22398 })();
22399 </script>
22400 <script nonce="{{ csp_nonce }}">(function(){
22401 var bundleBtn = document.getElementById('download-bundle-btn');
22402 if (bundleBtn) {
22403 bundleBtn.addEventListener('click', function() {
22404 bundleBtn.disabled = true;
22405 var orig = bundleBtn.textContent;
22406 bundleBtn.textContent = 'Preparing…';
22407 fetch('/api/runs/{{ run_id }}/bundle')
22408 .then(function(r) {
22409 if (!r.ok) throw new Error('HTTP ' + r.status);
22410 return r.blob();
22411 })
22412 .then(function(blob) {
22413 var url = URL.createObjectURL(blob);
22414 var a = document.createElement('a');
22415 a.href = url;
22416 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
22417 document.body.appendChild(a);
22418 a.click();
22419 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
22420 bundleBtn.disabled = false;
22421 bundleBtn.textContent = orig;
22422 })
22423 .catch(function(e) {
22424 bundleBtn.disabled = false;
22425 bundleBtn.textContent = orig;
22426 alert('Bundle download failed: ' + String(e));
22427 });
22428 });
22429 }
22430 })();</script>
22431 <script nonce="{{ csp_nonce }}">(function(){
22432 var dot=document.getElementById('status-dot');
22433 var pingEl=document.getElementById('server-ping-ms');
22434 var tipEl=document.getElementById('server-tip-ping');
22435 var fm=document.getElementById('footer-mode');
22436 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)';}}
22437 function doPing(){
22438 var t0=performance.now();
22439 fetch('/healthz',{cache:'no-store'})
22440 .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);})
22441 .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)';}});
22442 }
22443 doPing();
22444 setInterval(doPing,5000);
22445 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
22446 })();</script>
22447 {% if let Some(banner) = report_header_footer %}
22448 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
22449 {% endif %}
22450</body>
22451</html>
22452"##,
22453 ext = "html"
22454)]
22455#[allow(clippy::struct_excessive_bools)]
22457struct ResultTemplate {
22458 version: &'static str,
22459 report_title: String,
22460 project_path: String,
22461 output_dir: String,
22462 run_id: String,
22463 files_analyzed: u64,
22464 files_skipped: u64,
22465 physical_lines: u64,
22466 code_lines: u64,
22467 comment_lines: u64,
22468 blank_lines: u64,
22469 mixed_lines: u64,
22470 functions: u64,
22471 classes: u64,
22472 variables: u64,
22473 imports: u64,
22474 html_url: Option<String>,
22475 pdf_url: Option<String>,
22476 json_url: Option<String>,
22477 html_download_url: Option<String>,
22478 pdf_download_url: Option<String>,
22479 json_download_url: Option<String>,
22480 html_path: Option<String>,
22481 json_path: Option<String>,
22482 prev_run_id: Option<String>,
22483 prev_run_timestamp: Option<String>,
22484 prev_run_code_lines: Option<u64>,
22485 prev_fa_str: String,
22487 prev_fs_str: String,
22488 prev_pl_str: String,
22489 prev_cl_str: String,
22490 prev_cml_str: String,
22491 prev_bl_str: String,
22492 delta_fa_str: String,
22494 delta_fa_class: String,
22495 delta_fs_str: String,
22496 delta_fs_class: String,
22497 delta_pl_str: String,
22498 delta_pl_class: String,
22499 delta_cl_str: String,
22500 delta_cl_class: String,
22501 delta_cml_str: String,
22502 delta_cml_class: String,
22503 delta_bl_str: String,
22504 delta_bl_class: String,
22505 delta_lines_added: Option<i64>,
22507 delta_lines_removed: Option<i64>,
22508 delta_lines_net_str: String,
22509 delta_lines_net_class: String,
22510 delta_files_added: Option<usize>,
22511 delta_files_removed: Option<usize>,
22512 delta_files_modified: Option<usize>,
22513 delta_files_unchanged: Option<usize>,
22514 delta_unmodified_lines: Option<u64>,
22515 git_branch: Option<String>,
22517 git_branch_url: Option<String>,
22518 git_commit: Option<String>,
22519 git_commit_long: Option<String>,
22520 git_author: Option<String>,
22521 git_commit_url: Option<String>,
22522 scan_performed_by: String,
22524 scan_time_display: String,
22525 os_display: String,
22526 test_count: u64,
22527 prev_scan_count: usize,
22529 current_scan_number: usize,
22530 submodule_rows: Vec<SubmoduleRow>,
22532 scan_config_url: String,
22533 lang_chart_json: String,
22534 #[allow(dead_code)]
22536 scatter_chart_json: String,
22537 #[allow(dead_code)]
22538 semantic_chart_json: String,
22539 #[allow(dead_code)]
22540 submodule_chart_json: String,
22541 #[allow(dead_code)]
22542 has_submodule_data: bool,
22543 #[allow(dead_code)]
22544 has_semantic_data: bool,
22545 pdf_generating: bool,
22546 csp_nonce: String,
22547 confluence_configured: bool,
22549 server_mode: bool,
22550 report_header_footer: Option<String>,
22552 run_id_short: String,
22553 #[allow(dead_code)]
22555 is_offline: bool,
22556 cyclomatic_complexity: u64,
22558 lsloc: Option<u64>,
22560 uloc: u64,
22562 dryness_pct_str: String,
22564 duplicate_group_count: usize,
22566 has_cocomo: bool,
22568 cocomo_effort_str: String,
22570 cocomo_duration_str: String,
22572 cocomo_staff_str: String,
22574 cocomo_ksloc_str: String,
22576 cocomo_mode_label: String,
22578 cocomo_mode_tooltip: String,
22580 complexity_alert: u32,
22582}
22583
22584#[derive(Template)]
22585#[template(
22586 source = r##"
22587<!doctype html>
22588<html lang="en">
22589<head>
22590 <meta charset="utf-8">
22591 <meta name="viewport" content="width=device-width, initial-scale=1">
22592 <title>OxideSLOC | Analyzing…</title>
22593 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22594 <style nonce="{{ csp_nonce }}">
22595 :root {
22596 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22597 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22598 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22599 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22600 }
22601 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22602 *{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;}
22603 .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);}
22604 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22605 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
22606 .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));}
22607 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22608 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22609 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22610 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22611 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22612 @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; } }
22613 .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;}
22614 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22615 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22616 .page-body{padding:32px 24px 36px;}
22617 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
22618 .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;}
22619 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
22620 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
22621 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
22622 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
22623 .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;}
22624 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
22625 .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;}
22626 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
22627 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
22628 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
22629 .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;}
22630 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
22631 .hidden{display:none!important;}
22632 .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;}
22633 .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;}
22634 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
22635 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
22636 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
22637 .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);}
22638 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
22639 .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;}
22640 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
22641 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22642 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22643 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
22644 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22645 .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;}
22646 @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));}}
22647 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22648 .site-footer a{color:var(--muted);}
22649 .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;}
22650 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
22651 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
22652 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
22653 </style>
22654</head>
22655<body>
22656 <div class="background-watermarks" aria-hidden="true">
22657 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22658 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22659 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22660 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22661 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22662 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22663 </div>
22664 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22665 <nav class="top-nav">
22666 <div class="top-nav-inner">
22667 <a href="/" class="brand">
22668 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
22669 <div class="brand-copy">
22670 <h1 class="brand-title">OxideSLOC</h1>
22671 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
22672 </div>
22673 </a>
22674 <div class="nav-right">
22675 <a class="nav-pill" href="/">Home</a>
22676 <div class="nav-dropdown">
22677 <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>
22678 <div class="nav-dropdown-menu">
22679 <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>
22680 </div>
22681 </div>
22682 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22683 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22684 <div class="nav-dropdown">
22685 <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>
22686 <div class="nav-dropdown-menu">
22687 <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>
22688 </div>
22689 </div>
22690 <div class="server-status-wrap" id="server-status-wrap">
22691 <div class="nav-pill server-online-pill" id="server-status-pill">
22692 <span class="status-dot" id="status-dot"></span>
22693 <span id="server-status-label">Server</span>
22694 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22695 </div>
22696 <div class="server-status-tip">
22697 OxideSLOC is running — accessible on your network.
22698 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22699 </div>
22700 </div>
22701 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22702 <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>
22703 </button>
22704 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22705 <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>
22706 <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>
22707 </button>
22708 </div>
22709 </div>
22710 </nav>
22711 <div class="page-body">
22712 <div class="wait-panel">
22713 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
22714 <h2 class="wait-title">Analyzing your project…</h2>
22715 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
22716 <div class="path-block">{{ project_path }}</div>
22717 <div class="metrics-row">
22718 <div class="metric-card">
22719 <div class="metric-label">Elapsed</div>
22720 <div class="metric-value" id="elapsed">0s</div>
22721 </div>
22722 <div class="metric-card">
22723 <div class="metric-label">Phase</div>
22724 <div class="metric-value" id="phase">Starting</div>
22725 </div>
22726 <div class="metric-card hidden" id="files-card">
22727 <div class="metric-label">Files</div>
22728 <div class="metric-value" id="files-progress">0</div>
22729 </div>
22730 </div>
22731 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
22732 <div class="warn-slow hidden" id="warn-slow">
22733 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.
22734 </div>
22735 <div class="err-panel hidden" id="err-panel">
22736 <strong>Analysis failed</strong>
22737 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
22738 </div>
22739 <div class="actions hidden" id="actions">
22740 <a href="/scan" class="btn-primary">Try Again</a>
22741 <a href="/view-reports" class="btn-outline">View Reports</a>
22742 </div>
22743 </div>
22744 </div>
22745 <script nonce="{{ csp_nonce }}">
22746 (function() {
22747 var WAIT_ID = {{ wait_id_json|safe }};
22748 var startTime = Date.now();
22749 var pollInterval = 1500;
22750 var retries = 0;
22751 var maxRetries = 5;
22752 var warnShown = false;
22753
22754 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();}
22755
22756 function elapsed() {
22757 return Math.floor((Date.now() - startTime) / 1000);
22758 }
22759
22760 function updateElapsed() {
22761 var s = elapsed();
22762 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
22763 }
22764
22765 function setPhase(txt) {
22766 document.getElementById('phase').textContent = txt;
22767 }
22768
22769 var elapsedTimer = setInterval(updateElapsed, 1000);
22770
22771 function poll() {
22772 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
22773 .then(function(r) {
22774 if (!r.ok) throw new Error('HTTP ' + r.status);
22775 return r.json();
22776 })
22777 .then(function(data) {
22778 retries = 0;
22779 if (data.state === 'complete') {
22780 clearInterval(elapsedTimer);
22781 setPhase('Done');
22782 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
22783 } else if (data.state === 'failed') {
22784 clearInterval(elapsedTimer);
22785 setPhase('Failed');
22786 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
22787 document.getElementById('err-panel').classList.remove('hidden');
22788 document.getElementById('actions').classList.remove('hidden');
22789 } else {
22790 // still running
22791 var s = elapsed();
22792 if (s > 90 && !warnShown) {
22793 warnShown = true;
22794 document.getElementById('warn-slow').classList.remove('hidden');
22795 }
22796 setPhase(data.phase || 'Running');
22797 var fd = data.files_done || 0, ft = data.files_total || 0;
22798 if (ft > 0) {
22799 var card = document.getElementById('files-card');
22800 if (card) card.classList.remove('hidden');
22801 var fp = document.getElementById('files-progress');
22802 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
22803 }
22804 setTimeout(poll, pollInterval);
22805 }
22806 })
22807 .catch(function(err) {
22808 retries++;
22809 if (retries >= maxRetries) {
22810 clearInterval(elapsedTimer);
22811 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
22812 document.getElementById('err-panel').classList.remove('hidden');
22813 document.getElementById('actions').classList.remove('hidden');
22814 } else {
22815 // exponential back-off capped at 8s
22816 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
22817 }
22818 });
22819 }
22820
22821 setTimeout(poll, pollInterval);
22822
22823 // If the browser restores this page from bfcache (Back after viewing results),
22824 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
22825 window.addEventListener("pageshow", function(e) {
22826 if (e.persisted) { setTimeout(poll, 200); }
22827 });
22828 })();
22829 </script>
22830 <footer class="site-footer">
22831 local code analysis - metrics, history and reports
22832 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22833 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22834 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22835 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22836 · <a href="/api-docs" rel="noopener">REST API</a>
22837 </footer>
22838 <script nonce="{{ csp_nonce }}">
22839 (function(){
22840 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
22841 if(s==="dark")b.classList.add("dark-theme");
22842 var tt=document.getElementById("theme-toggle");
22843 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
22844 })();
22845 (function spawnCodeParticles(){
22846 var c=document.getElementById('code-particles');if(!c)return;
22847 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'];
22848 for(var i=0;i<32;i++){(function(idx){
22849 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
22850 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
22851 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
22852 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
22853 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
22854 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
22855 c.appendChild(el);
22856 })(i);}
22857 })();
22858 (function randomizeWatermarks(){
22859 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22860 var placed=[];
22861 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;}
22862 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];}
22863 var half=Math.floor(wms.length/2);
22864 wms.forEach(function(img,i){
22865 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
22866 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
22867 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
22868 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
22869 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
22870 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
22871 });
22872 })();
22873 </script>
22874 <script nonce="{{ csp_nonce }}">
22875 (function(){
22876 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'}];
22877 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);});}
22878 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22879 function init(){
22880 var btn=document.getElementById('settings-btn');if(!btn)return;
22881 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22882 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>';
22883 document.body.appendChild(m);
22884 var g=document.getElementById('scheme-grid');
22885 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);});
22886 var cl=document.getElementById('settings-close');
22887 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);
22888 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');});
22889 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22890 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22891 }
22892 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22893 }());
22894 </script>
22895 <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]';
22896 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;}
22897 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>
22898</body>
22899</html>
22900"##,
22901 ext = "html"
22902)]
22903struct ScanWaitTemplate {
22904 version: &'static str,
22905 wait_id_json: String,
22906 project_path: String,
22907 csp_nonce: String,
22908}
22909
22910#[derive(Template)]
22911#[template(
22912 source = r##"
22913<!doctype html>
22914<html lang="en">
22915<head>
22916 <meta charset="utf-8">
22917 <meta name="viewport" content="width=device-width, initial-scale=1">
22918 <title>OxideSLOC | Error</title>
22919 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22920 <style nonce="{{ csp_nonce }}">
22921 :root {
22922 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22923 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22924 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22925 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22926 }
22927 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22928 *{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;}
22929 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22930 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22931 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
22932 .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);}
22933 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22934 .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));}
22935 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22936 .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;}
22937 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22938 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22939 @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; } }
22940 .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;}
22941 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22942 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22943 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22944 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22945 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22946 .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;}
22947 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22948 .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);}
22949 .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;}
22950 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22951 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22952 .settings-modal-body{padding:14px 16px 16px;}
22953 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22954 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22955 .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;}
22956 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22957 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22958 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22959 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22960 .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;}
22961 .tz-select:focus{border-color:var(--oxide);}
22962 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
22963 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22964 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
22965 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
22966 .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;}
22967 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
22968 .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);}
22969 .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;}
22970 .btn-secondary:hover{background:var(--line);}
22971 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
22972 .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;}
22973 .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;}
22974 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
22975 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
22976 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
22977 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
22978 .bug-report-panel.open{display:flex;}
22979 .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;}
22980 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
22981 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
22982 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
22983 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
22984 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
22985 .br-network-badge.online .br-net-dot{background:#2a6846;}
22986 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
22987 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
22988 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
22989 .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;}
22990 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
22991 .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;}
22992 .btn-sm:hover{background:var(--line);}
22993 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
22994 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
22995 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
22996 .bug-report-hint a:hover{text-decoration:underline;}
22997 .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;}
22998 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
22999 .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;}
23000 .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;}
23001 .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;}
23002 @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));}}
23003 .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;}
23004 </style>
23005</head>
23006<body>
23007 <div class="background-watermarks" aria-hidden="true">
23008 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23009 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23010 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23011 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23012 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23013 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23014 </div>
23015 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23016 <div class="top-nav">
23017 <div class="top-nav-inner">
23018 <a class="brand" href="/">
23019 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23020 <div class="brand-copy">
23021 <div class="brand-title">OxideSLOC</div>
23022 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23023 </div>
23024 </a>
23025 <div class="nav-right">
23026 <a class="nav-pill" href="/">Home</a>
23027 <div class="nav-dropdown">
23028 <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>
23029 <div class="nav-dropdown-menu">
23030 <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>
23031 </div>
23032 </div>
23033 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23034 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23035 <div class="nav-dropdown">
23036 <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>
23037 <div class="nav-dropdown-menu">
23038 <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>
23039 </div>
23040 </div>
23041 <div class="server-status-wrap" id="server-status-wrap">
23042 <div class="nav-pill server-online-pill" id="server-status-pill">
23043 <span class="status-dot" id="status-dot"></span>
23044 <span id="server-status-label">Server</span>
23045 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23046 </div>
23047 <div class="server-status-tip">
23048 OxideSLOC is running — accessible on your network.
23049 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23050 </div>
23051 </div>
23052 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23053 <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>
23054 </button>
23055 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23056 <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>
23057 <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>
23058 </button>
23059 </div>
23060 </div>
23061 </div>
23062
23063 <div class="page">
23064 <div class="panel">
23065 <h1>Error</h1>
23066 <div class="error-box" id="error-msg-text">{{ message }}</div>
23067 <div id="br-meta" hidden
23068 data-version="{{ version }}"
23069 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
23070 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
23071 <div class="actions">
23072 <a class="btn-primary" href="/scan">Back to setup</a>
23073 {% if let Some(report_url) = last_report_url %}
23074 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
23075 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
23076 {% else %}
23077 <a class="btn-secondary" href="/view-reports">View Reports</a>
23078 {% endif %}
23079 </div>
23080 <div class="bug-report-section" id="bug-report-section">
23081 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
23082 <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>
23083 Generate Bug Report
23084 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23085 </button>
23086 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
23087 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
23088 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
23089 <div class="bug-report-btns">
23090 <button type="button" class="btn-sm" id="bug-report-copy">
23091 <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>
23092 Copy to clipboard
23093 </button>
23094 <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;">
23095 <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>
23096 Open GitHub Issue
23097 </a>
23098 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
23099 <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>
23100 Save as file
23101 </button>
23102 </div>
23103 <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>
23104 <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>
23105 </div>
23106 </div>
23107 </div>
23108 </div>
23109 <footer class="site-footer">
23110 oxide-sloc v{{ version }} — local code metrics workbench ·
23111 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23112 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23113 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23114 · <a href="/api-docs" rel="noopener">REST API</a>
23115 </footer>
23116 <script nonce="{{ csp_nonce }}">(function(){
23117 var meta=document.getElementById('br-meta');
23118 var pre=document.getElementById('bug-report-pre');
23119 var copyBtn=document.getElementById('bug-report-copy');
23120 var trigger=document.getElementById('bug-report-trigger');
23121 var panel=document.getElementById('bug-report-panel');
23122 var networkBadge=document.getElementById('br-network-badge');
23123 var networkLabel=document.getElementById('br-network-label');
23124 var ghLink=document.getElementById('bug-report-github-link');
23125 var saveBtn=document.getElementById('bug-report-save');
23126 var hintOnline=document.getElementById('br-hint-online');
23127 var hintOffline=document.getElementById('br-hint-offline');
23128 if(!meta||!pre)return;
23129 var ver=meta.getAttribute('data-version')||'';
23130 var runId=meta.getAttribute('data-run-id')||'';
23131 var code=meta.getAttribute('data-error-code')||'';
23132 var msgEl=document.getElementById('error-msg-text');
23133 var msg=msgEl?msgEl.textContent.trim():'';
23134 function getBrowser(){
23135 var ua=navigator.userAgent;
23136 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
23137 if(!m)return 'Unknown browser';
23138 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
23139 return n+' '+m[2];
23140 }
23141 var lines=['oxide-sloc Bug Report','==============================',''];
23142 lines.push('App version: v'+ver);
23143 if(code)lines.push('HTTP status: '+code);
23144 if(runId)lines.push('Run ID: '+runId);
23145 lines.push('Page: '+window.location.pathname+(window.location.search||''));
23146 lines.push('Timestamp: '+new Date().toISOString());
23147 lines.push('Browser: '+getBrowser());
23148 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
23149 lines.push('');
23150 lines.push('Error message:');
23151 lines.push(msg);
23152 lines.push('');
23153 lines.push('Steps to reproduce:');
23154 lines.push(' 1. ');
23155 lines.push('');
23156 lines.push('Expected behavior:');
23157 lines.push(' ');
23158 pre.textContent=lines.join('\n');
23159 function applyNetwork(online){
23160 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
23161 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
23162 if(ghLink){
23163 if(online){
23164 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
23165 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
23166 }
23167 ghLink.style.display=online?'inline-flex':'none';
23168 }
23169 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
23170 if(hintOnline)hintOnline.style.display=online?'block':'none';
23171 if(hintOffline)hintOffline.style.display=online?'none':'block';
23172 }
23173 applyNetwork(navigator.onLine);
23174 var probed=false;
23175 function probeNetwork(){
23176 if(probed)return;probed=true;
23177 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
23178 var probeIdx=0;
23179 function tryNext(){
23180 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
23181 var u=probeUrls[probeIdx++];
23182 var c2=new AbortController();
23183 var t2=setTimeout(function(){c2.abort();},4000);
23184 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
23185 .then(function(){clearTimeout(t2);applyNetwork(true);})
23186 .catch(function(){clearTimeout(t2);tryNext();});
23187 }
23188 tryNext();
23189 }
23190 if(trigger&&panel){
23191 trigger.addEventListener('click',function(){
23192 var open=panel.classList.toggle('open');
23193 trigger.classList.toggle('open',open);
23194 trigger.setAttribute('aria-expanded',open?'true':'false');
23195 if(open)probeNetwork();
23196 });
23197 }
23198 if(copyBtn){
23199 copyBtn.addEventListener('click',function(){
23200 var txt=pre.textContent;
23201 if(navigator.clipboard&&navigator.clipboard.writeText){
23202 navigator.clipboard.writeText(txt).then(function(){
23203 copyBtn.textContent='✓ Copied!';
23204 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);
23205 });
23206 }else{
23207 var ta=document.createElement('textarea');
23208 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
23209 document.body.appendChild(ta);ta.select();
23210 try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
23211 document.body.removeChild(ta);
23212 }
23213 });
23214 }
23215 if(saveBtn){
23216 saveBtn.addEventListener('click',function(){
23217 var txt=pre.textContent;
23218 var blob=new Blob([txt],{type:'text/plain'});
23219 var url=URL.createObjectURL(blob);
23220 var a=document.createElement('a');
23221 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
23222 document.body.appendChild(a);a.click();
23223 document.body.removeChild(a);URL.revokeObjectURL(url);
23224 });
23225 }
23226 })();</script>
23227 <script nonce="{{ csp_nonce }}">
23228 (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");});})();
23229 (function spawnCodeParticles() {
23230 var container = document.getElementById('code-particles');
23231 if (!container) return;
23232 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'];
23233 for (var i = 0; i < 38; i++) {
23234 (function(idx) {
23235 var el = document.createElement('span');
23236 el.className = 'code-particle';
23237 el.textContent = snippets[idx % snippets.length];
23238 var left = Math.random() * 94 + 2;
23239 var top = Math.random() * 88 + 6;
23240 var dur = (Math.random() * 10 + 9).toFixed(1);
23241 var delay = (Math.random() * 18).toFixed(1);
23242 var rot = (Math.random() * 26 - 13).toFixed(1);
23243 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23244 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';
23245 container.appendChild(el);
23246 })(i);
23247 }
23248 })();
23249 (function randomizeWatermarks() {
23250 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23251 var placed = [];
23252 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; }
23253 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]; }
23254 var half = Math.floor(wms.length/2);
23255 wms.forEach(function(img, i) {
23256 var pos = pick(i < half);
23257 var w = Math.floor(Math.random()*60+80);
23258 var rot = (Math.random()*40-20).toFixed(1);
23259 var op = (Math.random()*0.08+0.05).toFixed(2);
23260 var animDur = (Math.random()*6+5).toFixed(1);
23261 var animDelay = (Math.random()*10).toFixed(1);
23262 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';
23263 });
23264 })();
23265 </script>
23266 <script nonce="{{ csp_nonce }}">
23267 (function(){
23268 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'}];
23269 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);});}
23270 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23271 function init(){
23272 var btn=document.getElementById('settings-btn');if(!btn)return;
23273 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23274 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>';
23275 document.body.appendChild(m);
23276 var g=document.getElementById('scheme-grid');
23277 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);});
23278 var cl=document.getElementById('settings-close');
23279 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);
23280 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');});
23281 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23282 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23283 }
23284 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23285 }());
23286 </script>
23287 <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]';
23288 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;}
23289 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>
23290</body>
23291</html>
23292"##,
23293 ext = "html"
23294)]
23295struct ErrorTemplate {
23296 message: String,
23297 last_report_url: Option<String>,
23299 last_report_label: Option<String>,
23301 run_id: Option<String>,
23303 error_code: Option<u16>,
23305 csp_nonce: String,
23306 version: &'static str,
23307}
23308
23309#[derive(Template)]
23312#[template(
23313 source = r##"
23314<!doctype html>
23315<html lang="en">
23316<head>
23317 <meta charset="utf-8">
23318 <meta name="viewport" content="width=device-width, initial-scale=1">
23319 <title>OxideSLOC | Locate Report</title>
23320 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23321 <style nonce="{{ csp_nonce }}">
23322 :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);}
23323 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
23324 *{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;}
23325 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23326 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23327 .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);}
23328 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23329 .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));}
23330 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23331 .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;}
23332 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23333 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23334 @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;}}
23335 .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;}
23336 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23337 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23338 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23339 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23340 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
23341 .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;}
23342 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23343 .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);}
23344 .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;}
23345 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23346 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23347 .settings-modal-body{padding:14px 16px 16px;}
23348 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23349 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23350 .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;}
23351 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23352 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23353 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23354 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23355 .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;}
23356 .tz-select:focus{border-color:var(--oxide);}
23357 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23358 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23359 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23360 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
23361 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
23362 .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;}
23363 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
23364 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23365 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23366 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23367 .locate-row{display:flex;gap:8px;align-items:stretch;}
23368 .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;}
23369 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23370 body.dark-theme .locate-input{background:var(--surface-2);}
23371 .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;}
23372 .warning-banner.show{display:flex;}
23373 .warning-banner svg{flex:0 0 auto;}
23374 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
23375 .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;}
23376 .error-inline.show{display:flex;}
23377 .error-inline svg{flex:0 0 auto;margin-top:2px;}
23378 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
23379 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
23380 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
23381 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
23382 .err-kv-p{margin:0 0 4px;}
23383 .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;}
23384 .success-inline.show{display:flex;}
23385 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23386 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
23387 .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;}
23388 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
23389 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
23390 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
23391 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
23392 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
23393 .fh-row:last-child{border-bottom:none;}
23394 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
23395 .fh-dir{font-weight:800;color:var(--text);}
23396 .fh-hl{color:var(--oxide);font-weight:700;}
23397 .fh-muted{color:var(--muted);}
23398 .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;}
23399 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
23400 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
23401 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
23402 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
23403 .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;}
23404 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
23405 .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;}
23406 .btn-secondary:hover{background:var(--line);}
23407 .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;}
23408 .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;}
23409 .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;}
23410 @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));}}
23411 .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;}
23412 .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;}
23413 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23414 </style>
23415</head>
23416<body>
23417 <div class="background-watermarks" aria-hidden="true">
23418 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23419 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23420 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23421 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23422 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23423 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23424 </div>
23425 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23426 <div class="top-nav">
23427 <div class="top-nav-inner">
23428 <a class="brand" href="/">
23429 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23430 <div class="brand-copy">
23431 <div class="brand-title">OxideSLOC</div>
23432 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23433 </div>
23434 </a>
23435 <div class="nav-right">
23436 <a class="nav-pill" href="/">Home</a>
23437 <div class="nav-dropdown">
23438 <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>
23439 <div class="nav-dropdown-menu">
23440 <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>
23441 </div>
23442 </div>
23443 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23444 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23445 <div class="nav-dropdown">
23446 <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>
23447 <div class="nav-dropdown-menu">
23448 <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>
23449 </div>
23450 </div>
23451 <div class="server-status-wrap" id="server-status-wrap">
23452 <div class="nav-pill server-online-pill" id="server-status-pill">
23453 <span class="status-dot" id="status-dot"></span>
23454 <span id="server-status-label">Server</span>
23455 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23456 </div>
23457 <div class="server-status-tip">
23458 OxideSLOC is running — accessible on your network.
23459 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23460 </div>
23461 </div>
23462 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23463 <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>
23464 </button>
23465 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23466 <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>
23467 <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>
23468 </button>
23469 </div>
23470 </div>
23471 </div>
23472
23473 <div class="page">
23474 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
23475 <div class="panel">
23476 <h1>Report File Not Found</h1>
23477 <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>
23478 <div class="field-label">Missing file</div>
23479 <div class="filename-chip">
23480 <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>
23481 {{ expected_filename }}
23482 </div>
23483 <div class="locate-section">
23484 <h2>Locate Scan Output Folder</h2>
23485 <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>
23486 <p>OxideSLOC will find the correct files inside automatically.</p>
23487 <div class="locate-row">
23488 <input type="text" id="locate-file-input"
23489 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
23490 class="locate-input" autocomplete="off" spellcheck="false">
23491 {% if !server_mode %}
23492 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
23493 {% endif %}
23494 </div>
23495 <div class="warning-banner" id="filename-warning">
23496 <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>
23497 <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>
23498 </div>
23499 <div class="error-inline" id="locate-error">
23500 <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>
23501 <span id="locate-error-text"></span>
23502 </div>
23503 <div class="success-inline" id="locate-success">
23504 <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>
23505 <span>Scan restored — loading report…</span>
23506 </div>
23507 <div class="btn-row">
23508 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
23509 <a class="btn-secondary" href="/view-reports">View Reports</a>
23510 </div>
23511 <div class="folder-hint-shell">
23512 <div class="folder-hint-hdr">
23513 <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>
23514 Expected Folder Structure — Select the Top-Level Folder
23515 </div>
23516 <div class="folder-hint-body">
23517 <div class="fh-row">
23518 <span class="fh-tog">►</span>
23519 <span class="fh-dir">project_20260601-0029-…/</span>
23520 <span class="fh-badge">← select this</span>
23521 </div>
23522 <div class="fh-row fh-i1">
23523 <span class="fh-tog">►</span>
23524 <span class="fh-dir">html/</span>
23525 </div>
23526 <div class="fh-row fh-i2">
23527 <span class="fh-bul">•</span>
23528 <span class="fh-hl">{{ expected_filename }}</span>
23529 </div>
23530 <div class="fh-row fh-i1">
23531 <span class="fh-tog">►</span>
23532 <span class="fh-dir">json/</span>
23533 </div>
23534 <div class="fh-row fh-i2">
23535 <span class="fh-bul">•</span>
23536 <span class="fh-muted">result_*.json</span>
23537 </div>
23538 <div class="fh-row fh-i1">
23539 <span class="fh-tog">►</span>
23540 <span class="fh-dir">pdf/</span>
23541 </div>
23542 <div class="fh-row fh-i2">
23543 <span class="fh-bul">•</span>
23544 <span class="fh-muted">report_*.pdf</span>
23545 </div>
23546 <div class="fh-row fh-i1">
23547 <span class="fh-tog">►</span>
23548 <span class="fh-dir">excel/</span>
23549 </div>
23550 <div class="fh-row fh-i2">
23551 <span class="fh-bul">•</span>
23552 <span class="fh-muted">report_*.csv report_*.xlsx</span>
23553 </div>
23554 </div>
23555 </div>
23556 </div>
23557 </div>
23558 </div>
23559 <footer class="site-footer">
23560 oxide-sloc v{{ version }} — local code metrics workbench ·
23561 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23562 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23563 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23564 · <a href="/api-docs" rel="noopener">REST API</a>
23565 </footer>
23566 <script nonce="{{ csp_nonce }}">(function(){
23567 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
23568 if(s==="dark")b.classList.add("dark-theme");
23569 document.getElementById("theme-toggle").addEventListener("click",function(){
23570 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
23571 });
23572 })();</script>
23573 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
23574 var c=document.getElementById('code-particles');if(!c)return;
23575 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'];
23576 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);}
23577 })();
23578 (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>
23579 <script nonce="{{ csp_nonce }}">(function(){
23580 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'}];
23581 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);});}
23582 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23583 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');});}
23584 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23585 }());</script>
23586 <script nonce="{{ csp_nonce }}">(function(){
23587 var meta=document.getElementById('locate-meta');
23588 var inp=document.getElementById('locate-file-input');
23589 var browseBtn=document.getElementById('browse-locate-btn');
23590 var submitBtn=document.getElementById('locate-submit-btn');
23591 var warning=document.getElementById('filename-warning');
23592 var errBox=document.getElementById('locate-error');
23593 var errText=document.getElementById('locate-error-text');
23594 var okBox=document.getElementById('locate-success');
23595 var expected=meta?meta.getAttribute('data-expected'):'';
23596 var runId=meta?meta.getAttribute('data-run-id'):'';
23597 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
23598 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
23599 function showErr(msg){
23600 if(errText){
23601 errText.innerHTML='';
23602 var lines=msg.split('\n');
23603 var hasPairs=lines.some(function(l){return / : /.test(l);});
23604 if(!hasPairs){errText.textContent=msg;}
23605 else{
23606 var frag=document.createDocumentFragment();var tbl=null;
23607 lines.forEach(function(line){
23608 var m=line.match(/^(.*?) : (.*)$/);
23609 if(m){
23610 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
23611 var tr=document.createElement('tr');
23612 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
23613 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
23614 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
23615 } else {
23616 tbl=null;
23617 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
23618 }
23619 });
23620 errText.appendChild(frag);
23621 }
23622 }
23623 if(errBox)errBox.classList.add('show');
23624 if(okBox)okBox.classList.remove('show');
23625 }
23626 function clearErr(){
23627 if(errBox)errBox.classList.remove('show');
23628 if(okBox)okBox.classList.remove('show');
23629 }
23630 function validate(){
23631 var val=inp?inp.value.trim():'';
23632 clearErr();
23633 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
23634 if(submitBtn)submitBtn.disabled=false;
23635 if(warning){
23636 var name=basename(val);
23637 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
23638 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
23639 else warning.classList.remove('show');
23640 }
23641 }
23642 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
23643 if(browseBtn){
23644 browseBtn.addEventListener('click',function(){
23645 browseBtn.disabled=true;browseBtn.textContent='...';
23646 fetch('/pick-directory')
23647 .then(function(r){return r.ok?r.json():{cancelled:true};})
23648 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
23649 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23650 });
23651 }
23652 if(submitBtn){
23653 submitBtn.addEventListener('click',function(){
23654 var folder=inp?inp.value.trim():'';
23655 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
23656 clearErr();
23657 submitBtn.disabled=true;submitBtn.textContent='Restoring…';
23658 var body=new URLSearchParams();
23659 body.set('file_path',folder);
23660 body.set('redirect_url',redirectUrl);
23661 body.set('expected_run_id',runId);
23662 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23663 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
23664 .then(function(d){
23665 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23666 if(d&&d.ok){
23667 if(okBox)okBox.classList.add('show');
23668 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
23669 } else {
23670 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
23671 }
23672 })
23673 .catch(function(e){
23674 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23675 showErr('Network error: '+String(e));
23676 });
23677 });
23678 }
23679 })();</script>
23680 <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>
23681</body>
23682</html>
23683"##,
23684 ext = "html"
23685)]
23686struct LocateFileTemplate {
23687 run_id: String,
23688 artifact_type: String,
23689 expected_filename: String,
23690 server_mode: bool,
23691 csp_nonce: String,
23692 version: &'static str,
23693}
23694
23695#[derive(Template)]
23698#[template(
23699 source = r##"
23700<!doctype html>
23701<html lang="en">
23702<head>
23703 <meta charset="utf-8">
23704 <meta name="viewport" content="width=device-width, initial-scale=1">
23705 <title>OxideSLOC | Locate Scan Files</title>
23706 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23707 <style nonce="{{ csp_nonce }}">
23708 :root {
23709 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23710 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23711 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
23712 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23713 }
23714 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
23715 *{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;}
23716 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23717 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23718 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
23719 .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);}
23720 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23721 .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));}
23722 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23723 .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;}
23724 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23725 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23726 @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;}}
23727 .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;}
23728 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23729 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23730 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23731 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23732 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23733 .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;}
23734 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23735 .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);}
23736 .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;}
23737 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23738 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23739 .settings-modal-body{padding:14px 16px 16px;}
23740 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23741 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23742 .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;}
23743 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23744 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23745 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23746 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23747 .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;}
23748 .tz-select:focus{border-color:var(--oxide);}
23749 .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23750 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23751 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23752 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
23753 .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;}
23754 .error-box.hidden{display:none;}
23755 .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;}
23756 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23757 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23758 .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;}
23759 .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;}
23760 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
23761 .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;}
23762 .btn-secondary:hover{background:var(--line);}
23763 .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;}
23764 .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;}
23765 .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;}
23766 @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));}}
23767 .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;}
23768 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23769 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23770 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23771 .relocate-row{display:flex;gap:8px;align-items:stretch;}
23772 .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;}
23773 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23774 body.dark-theme .relocate-input{background:var(--surface-2);}
23775 </style>
23776</head>
23777<body>
23778 <div class="background-watermarks" aria-hidden="true">
23779 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23780 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23781 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23782 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23783 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23784 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23785 </div>
23786 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23787 <div class="top-nav">
23788 <div class="top-nav-inner">
23789 <a class="brand" href="/">
23790 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23791 <div class="brand-copy">
23792 <div class="brand-title">OxideSLOC</div>
23793 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23794 </div>
23795 </a>
23796 <div class="nav-right">
23797 <a class="nav-pill" href="/">Home</a>
23798 <div class="nav-dropdown">
23799 <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>
23800 <div class="nav-dropdown-menu">
23801 <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>
23802 </div>
23803 </div>
23804 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
23805 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23806 <div class="nav-dropdown">
23807 <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>
23808 <div class="nav-dropdown-menu">
23809 <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>
23810 </div>
23811 </div>
23812 <div class="server-status-wrap" id="server-status-wrap">
23813 <div class="nav-pill server-online-pill" id="server-status-pill">
23814 <span class="status-dot" id="status-dot"></span>
23815 <span id="server-status-label">Server</span>
23816 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23817 </div>
23818 <div class="server-status-tip">
23819 OxideSLOC is running — accessible on your network.
23820 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23821 </div>
23822 </div>
23823 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23824 <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>
23825 </button>
23826 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23827 <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>
23828 <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>
23829 </button>
23830 </div>
23831 </div>
23832 </div>
23833
23834 <div class="page">
23835 <div class="panel">
23836 <h1>Scan Files Moved</h1>
23837 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
23838 <div class="error-box" id="relocate-error-box">{{ message }}</div>
23839 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
23840 <div class="relocate-section">
23841 <h2>Locate Scan Output</h2>
23842 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
23843 <div class="relocate-row">
23844 <input type="text" id="relocate-folder" name="folder_path"
23845 value="{{ folder_hint }}"
23846 placeholder="Path to folder containing scan output..."
23847 class="relocate-input" autocomplete="off" spellcheck="false">
23848 {% if !server_mode %}
23849 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
23850 {% endif %}
23851 </div>
23852 <div style="margin-top:12px;">
23853 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
23854 </div>
23855 </div>
23856 <div class="actions">
23857 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
23858 <a class="btn-secondary" href="/view-reports">View Reports</a>
23859 </div>
23860 </div>
23861 </div>
23862 <footer class="site-footer">
23863 oxide-sloc v{{ version }} — local code metrics workbench ·
23864 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23865 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23866 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23867 · <a href="/api-docs" rel="noopener">REST API</a>
23868 </footer>
23869 <script nonce="{{ csp_nonce }}">
23870 (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");});})();
23871 (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);}})();
23872 (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;});})();
23873 </script>
23874 <script nonce="{{ csp_nonce }}">
23875 (function(){
23876 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'}];
23877 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);});}
23878 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23879 function init(){
23880 var btn=document.getElementById('settings-btn');if(!btn)return;
23881 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23882 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>';
23883 document.body.appendChild(m);
23884 var g=document.getElementById('scheme-grid');
23885 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);});
23886 var cl=document.getElementById('settings-close');
23887 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);
23888 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');});
23889 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23890 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23891 }
23892 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23893 }());
23894 (function(){
23895 var browseBtn=document.getElementById('browse-relocate-btn');
23896 if(browseBtn){
23897 browseBtn.addEventListener('click',function(){
23898 browseBtn.disabled=true;browseBtn.textContent='...';
23899 var inp=document.getElementById('relocate-folder');
23900 var hint=inp?inp.value:'';
23901 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
23902 .then(function(r){return r.ok?r.json():{cancelled:true};})
23903 .then(function(d){
23904 browseBtn.disabled=false;browseBtn.textContent='Browse…';
23905 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
23906 })
23907 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23908 });
23909 }
23910 var restoreBtn=document.getElementById('restore-btn');
23911 var errBox=document.getElementById('relocate-error-box');
23912 var okBox=document.getElementById('relocate-success-box');
23913 if(restoreBtn){
23914 restoreBtn.addEventListener('click',function(){
23915 var inp=document.getElementById('relocate-folder');
23916 var folder=inp?inp.value.trim():'';
23917 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
23918 restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
23919 var body=new URLSearchParams();
23920 body.set('run_id','{{ run_id }}');
23921 body.set('redirect_url','{{ redirect_url }}');
23922 body.set('folder_path',folder);
23923 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23924 .then(function(r){return r.json();})
23925 .then(function(d){
23926 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23927 if(d&&d.ok){
23928 if(errBox)errBox.classList.add('hidden');
23929 if(okBox){okBox.style.display='block';}
23930 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
23931 } else {
23932 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
23933 }
23934 })
23935 .catch(function(e){
23936 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23937 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
23938 });
23939 });
23940 }
23941 }());
23942 </script>
23943 <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]';
23944 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;}
23945 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>
23946</body>
23947</html>
23948"##,
23949 ext = "html"
23950)]
23951struct RelocateScanTemplate {
23952 message: String,
23953 run_id: String,
23954 folder_hint: String,
23955 redirect_url: String,
23956 server_mode: bool,
23957 csp_nonce: String,
23958 version: &'static str,
23959}
23960
23961#[derive(Template)]
23964#[template(
23965 source = r##"
23966<!doctype html>
23967<html lang="en">
23968<head>
23969 <meta charset="utf-8">
23970 <meta name="viewport" content="width=device-width, initial-scale=1">
23971 <title>OxideSLOC | View Reports</title>
23972 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23973 <style nonce="{{ csp_nonce }}">
23974 :root {
23975 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
23976 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23977 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23978 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23979 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
23980 }
23981 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; }
23982 *{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;}
23983 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23984 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23985 .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);}
23986 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23987 .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));}
23988 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23989 .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;}
23990 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23991 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23992 @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; } }
23993 .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;}
23994 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23995 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23996 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23997 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23998 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23999 .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;}
24000 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24001 .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);}
24002 .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;}
24003 .settings-close:hover{color:var(--text);background:var(--surface-2);}
24004 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24005 .settings-modal-body{padding:14px 16px 16px;}
24006 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24007 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24008 .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;}
24009 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24010 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24011 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24012 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24013 .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;}
24014 .tz-select:focus{border-color:var(--oxide);}
24015 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24016 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24017 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24018 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24019 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24020 .panel-meta{font-size:13px;color:var(--muted);}
24021 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24022 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24023 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24024 .per-page-label{font-size:13px;color:var(--muted);}
24025 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;}
24026 .filter-input{min-width:180px;cursor:text;}
24027 .table-wrap{width:100%;overflow-x:auto;}
24028 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
24029 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;}
24030 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24031 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24032 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24033 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24034 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24035 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24036 tr:last-child td{border-bottom:none;}
24037 tr:hover td{background:var(--surface-2);}
24038 .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);}
24039 .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);}
24040 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24041 .metric-num{font-weight:700;color:var(--text);}
24042 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24043 .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;}
24044 .btn:hover{background:var(--line);}
24045 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24046 .btn.primary:hover{opacity:.9;}
24047 .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;}
24048 .btn-back:hover{background:var(--line);}
24049 .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;}
24050 .export-btn:hover{background:var(--line);}
24051 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
24052 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
24053 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
24054 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24055 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24056 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24057 .pagination-info{font-size:13px;color:var(--muted);}
24058 .pagination-btns{display:flex;gap:6px;}
24059 .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;}
24060 .pg-btn:hover:not(:disabled){background:var(--line);}
24061 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24062 .pg-btn:disabled{opacity:.35;cursor:default;}
24063 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24064 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24065 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
24066 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24067 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24068 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24069 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
24070 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24071 .stat-chip:hover .stat-chip-tip{opacity:1;}
24072 .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;}
24073 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24074 .site-footer a{color:var(--muted);}
24075 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24076 .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%;}
24077 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
24078 .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;}
24079 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
24080 .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;}
24081 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
24082 .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;}
24083 .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;}
24084 .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;}
24085 @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));}}
24086 .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;}
24087 .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;}
24088 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24089 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24090 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24091 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24092 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24093 .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;}
24094 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24095 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24096 .watched-chip-rm:hover{color:var(--oxide);}
24097 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24098 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24099 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24100 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24101 .rpt-btn{min-width:58px;justify-content:center;}
24102 .flex-row{display:flex;align-items:center;gap:8px;}
24103 .report-cell{overflow:visible;white-space:normal;}
24104 #history-table col:nth-child(1){width:185px;}
24105 #history-table col:nth-child(2){width:220px;}
24106 #history-table col:nth-child(3){width:100px;}
24107 #history-table col:nth-child(4){width:72px;}
24108 #history-table col:nth-child(5){width:82px;}
24109 #history-table col:nth-child(6){width:82px;}
24110 #history-table col:nth-child(7){width:65px;}
24111 #history-table col:nth-child(8){width:90px;}
24112 #history-table col:nth-child(9){width:85px;}
24113 #history-table col:nth-child(10){width:115px;}
24114 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
24115 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
24116 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
24117 .submod-details summary::-webkit-details-marker{display:none;}
24118.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
24119 .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;}
24120 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
24121 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
24122 </style>
24123</head>
24124<body>
24125 <div class="background-watermarks" aria-hidden="true">
24126 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24127 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24128 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24129 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24130 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24131 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24132 </div>
24133 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24134 <div class="top-nav">
24135 <div class="top-nav-inner">
24136 <a class="brand" href="/">
24137 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24138 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
24139 </a>
24140 <div class="nav-right">
24141 <a class="nav-pill" href="/">Home</a>
24142 <div class="nav-dropdown">
24143 <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>
24144 <div class="nav-dropdown-menu">
24145 <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>
24146 </div>
24147 </div>
24148 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24149 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24150 <div class="nav-dropdown">
24151 <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>
24152 <div class="nav-dropdown-menu">
24153 <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>
24154 </div>
24155 </div>
24156 <div class="server-status-wrap" id="server-status-wrap">
24157 <div class="nav-pill server-online-pill" id="server-status-pill">
24158 <span class="status-dot" id="status-dot"></span>
24159 <span id="server-status-label">Server</span>
24160 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24161 </div>
24162 <div class="server-status-tip">
24163 OxideSLOC is running — accessible on your network.
24164 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24165 </div>
24166 </div>
24167 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24168 <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>
24169 </button>
24170 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24171 <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>
24172 <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>
24173 </button>
24174 </div>
24175 </div>
24176 </div>
24177
24178 <div class="page">
24179 {% if let Some(err) = browse_error %}
24180 <div class="toast-error">
24181 <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>
24182 {{ err }}
24183 </div>
24184 {% endif %}
24185 {% if linked_count > 0 %}
24186 <div class="toast-success">
24187 <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>
24188 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
24189 </div>
24190 {% endif %}
24191 <div class="watched-bar">
24192 <div class="watched-bar-left">
24193 <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>
24194 <span class="watched-label">Watched Folders</span>
24195 <div class="watched-chips">
24196 {% if server_mode %}
24197 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24198 {% else %}
24199 {% for dir in watched_dirs %}
24200 <span class="watched-chip">
24201 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24202 <form method="POST" action="/watched-dirs/remove" style="display:contents">
24203 <input type="hidden" name="folder_path" value="{{ dir }}">
24204 <input type="hidden" name="redirect_to" value="/view-reports">
24205 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
24206 </form>
24207 </span>
24208 {% endfor %}
24209 {% if watched_dirs.is_empty() %}
24210 <span class="watched-none">No folders watched — click Choose to add one</span>
24211 {% endif %}
24212 {% endif %}
24213 </div>
24214 </div>
24215 {% if !server_mode %}
24216 <div class="watched-bar-right">
24217 <button type="button" class="btn" id="add-watched-btn">
24218 <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>
24219 Choose
24220 </button>
24221 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24222 <input type="hidden" name="redirect_to" value="/view-reports">
24223 <button type="submit" class="btn">↻ Refresh</button>
24224 </form>
24225 </div>
24226 {% endif %}
24227 </div>
24228 {% if total_scans > 0 %}
24229 <div class="summary-strip">
24230 <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>
24231 <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>
24232 <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>
24233 <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>
24234 </div>
24235 {% endif %}
24236
24237 <section class="panel">
24238 <div class="panel-header">
24239 <div>
24240 <h1>View Reports</h1>
24241 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
24242 {% 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 %}
24243 </div>
24244 <div class="flex-row">
24245 <button type="button" class="export-btn" id="export-csv-btn">
24246 <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>
24247 Export CSV
24248 </button>
24249 <button type="button" class="export-btn" id="export-xls-btn">
24250 <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>
24251 Export Excel
24252 </button>
24253 </div>
24254 </div>
24255
24256 {% if entries.is_empty() %}
24257 <div class="empty-state">
24258 <strong>No reports with viewable HTML yet</strong>
24259 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.
24260 </div>
24261 {% else %}
24262 <div class="filter-row">
24263 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
24264 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24265 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
24266 </div>
24267 <div class="table-wrap">
24268 <table id="history-table">
24269 <colgroup>
24270 <col><col><col><col><col><col><col><col><col><col>
24271 </colgroup>
24272 <thead>
24273 <tr id="history-thead">
24274 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24275 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24276 <th>Run ID<div class="col-resize-handle"></div></th>
24277 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24278 <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>
24279 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24280 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24281 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24282 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24283 <th>Report<div class="col-resize-handle"></div></th>
24284 </tr>
24285 </thead>
24286 <tbody id="history-tbody">
24287 {% for entry in entries %}
24288 <tr class="history-row" data-run="{{ entry.run_id }}"
24289 data-timestamp="{{ entry.timestamp }}"
24290 data-project="{{ entry.project_label }}"
24291 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
24292 data-skipped="{{ entry.files_skipped }}"
24293 data-comments="{{ entry.comment_lines }}"
24294 data-blank="{{ entry.blank_lines }}"
24295 data-branch="{{ entry.git_branch }}"
24296 data-commit="{{ entry.git_commit }}"
24297 data-html-url="/runs/html/{{ entry.run_id }}">
24298 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
24299 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
24300 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
24301 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
24302 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
24303 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
24304 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
24305 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
24306 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip" title="{{ entry.git_commit }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
24307 <td class="report-cell">
24308 <div class="actions-cell">
24309 {% 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 %}
24310 {% 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 %}
24311 </div>
24312 {% if !entry.submodule_links.is_empty() %}
24313 <details class="submod-details">
24314 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
24315 <div class="submod-link-list">
24316 {% for sub in entry.submodule_links %}
24317 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
24318 {% endfor %}
24319 </div>
24320 </details>
24321 {% endif %}
24322 </td>
24323 </tr>
24324 {% endfor %}
24325 </tbody>
24326 </table>
24327 </div>
24328 <div class="pagination">
24329 <span class="pagination-info" id="pagination-info"></span>
24330 <div class="pagination-btns" id="pagination-btns"></div>
24331 <div class="flex-row">
24332 <span class="per-page-label">Show</span>
24333 <select class="per-page" id="per-page-sel">
24334 <option value="10">10 per page</option>
24335 <option value="25" selected>25 per page</option>
24336 <option value="50">50 per page</option>
24337 <option value="100">100 per page</option>
24338 </select>
24339 <span class="per-page-label" id="page-range-label"></span>
24340 </div>
24341 </div>
24342 {% endif %}
24343 </section>
24344 </div>
24345
24346 <footer class="site-footer">
24347 local code analysis - metrics, history and reports
24348 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24349 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24350 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24351 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24352 · <a href="/api-docs" rel="noopener">REST API</a>
24353 </footer>
24354
24355 <script nonce="{{ csp_nonce }}">
24356 (function () {
24357 // ── Theme ──────────────────────────────────────────────────────────────
24358 var storageKey = 'oxide-sloc-theme';
24359 var body = document.body;
24360 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
24361 var toggle = document.getElementById('theme-toggle');
24362 if (toggle) toggle.addEventListener('click', function () {
24363 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
24364 body.classList.toggle('dark-theme', next === 'dark');
24365 try { localStorage.setItem(storageKey, next); } catch(e) {}
24366 });
24367
24368 // ── State ─────────────────────────────────────────────────────────────
24369 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
24370 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
24371 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
24372
24373 // Aggregate stats from first (most recent) row
24374 if (allRows.length) {
24375 var first = allRows[0];
24376 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();}
24377 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>':'');}
24378 setChipVal('agg-code', first.dataset.code);
24379 setChipVal('agg-files', first.dataset.files);
24380 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
24381 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
24382 }
24383
24384 // ── Branch filter population ──────────────────────────────────────────
24385 (function() {
24386 var branches = {};
24387 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
24388 var sel = document.getElementById('branch-filter');
24389 if (sel) Object.keys(branches).sort().forEach(function(b) {
24390 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
24391 });
24392 })();
24393
24394 // ── Filter ────────────────────────────────────────────────────────────
24395 function getFilteredRows() {
24396 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
24397 var branch = ((document.getElementById('branch-filter') || {}).value || '');
24398 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
24399 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
24400 if (branch && (r.dataset.branch || '') !== branch) return false;
24401 return true;
24402 });
24403 }
24404
24405 // ── Pagination ────────────────────────────────────────────────────────
24406 function renderPage() {
24407 var filtered = getFilteredRows();
24408 var total = filtered.length;
24409 var totalPages = Math.max(1, Math.ceil(total / perPage));
24410 currentPage = Math.min(currentPage, totalPages);
24411 var start = (currentPage - 1) * perPage;
24412 var end = Math.min(start + perPage, total);
24413 var shown = {};
24414 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
24415 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
24416 r.style.display = shown[r.dataset.run] ? '' : 'none';
24417 });
24418 var rl = document.getElementById('page-range-label');
24419 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
24420 var info = document.getElementById('pagination-info');
24421 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
24422 var btns = document.getElementById('pagination-btns');
24423 if (!btns) return;
24424 btns.innerHTML = '';
24425 function makeBtn(lbl, pg, active, disabled) {
24426 var b = document.createElement('button');
24427 b.className = 'pg-btn' + (active ? ' active' : '');
24428 b.textContent = lbl; b.disabled = disabled;
24429 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
24430 return b;
24431 }
24432 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
24433 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
24434 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
24435 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
24436 }
24437
24438 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
24439 window.applyFilters = function() { currentPage = 1; renderPage(); };
24440
24441 // ── Sorting ───────────────────────────────────────────────────────────
24442 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
24443 function doSort(col, type, order) {
24444 var tbody = document.getElementById('history-tbody');
24445 if (!tbody) return;
24446 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24447 rows.sort(function(a, b) {
24448 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
24449 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
24450 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
24451 return va < vb ? 1 : va > vb ? -1 : 0;
24452 });
24453 rows.forEach(function(r) { tbody.appendChild(r); });
24454 currentPage = 1; renderPage();
24455 }
24456 sortHeaders.forEach(function(th) {
24457 th.addEventListener('click', function(e) {
24458 if (e.target.classList.contains('col-resize-handle')) return;
24459 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
24460 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
24461 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24462 th.classList.add('sort-' + sortOrder);
24463 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
24464 doSort(col, type, sortOrder);
24465 });
24466 });
24467
24468 // ── Column resize ─────────────────────────────────────────────────────
24469 (function() {
24470 var table = document.getElementById('history-table');
24471 if (!table) return;
24472 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
24473 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
24474 ths.forEach(function(th, i) {
24475 var handle = th.querySelector('.col-resize-handle');
24476 if (!handle || !cols[i]) return;
24477 var startX, startW;
24478 handle.addEventListener('mousedown', function(e) {
24479 e.stopPropagation(); e.preventDefault();
24480 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
24481 handle.classList.add('dragging');
24482 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
24483 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
24484 document.addEventListener('mousemove', onMove);
24485 document.addEventListener('mouseup', onUp);
24486 });
24487 });
24488 })();
24489
24490 // ── Reset view ────────────────────────────────────────────────────────
24491 window.resetView = function() {
24492 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
24493 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
24494 sortCol = null; sortOrder = 'asc';
24495 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24496 var tbody = document.getElementById('history-tbody');
24497 if (tbody) {
24498 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24499 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
24500 rows.forEach(function(r) { tbody.appendChild(r); });
24501 }
24502 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
24503 var table = document.getElementById('history-table');
24504 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
24505 currentPage = 1; renderPage();
24506 };
24507
24508 renderPage();
24509
24510 // ── Export helpers ────────────────────────────────────────────────────
24511 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
24512 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
24513 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);}
24514 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;');}
24515 function slocXlsx(fname,sheet,hdrs,rows){
24516 var enc=new TextEncoder();
24517 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;}
24518 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;}
24519 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
24520 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
24521 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24522 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;}
24523 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];}
24524 var rx='<row r="1">';
24525 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
24526 rx+='</row>';
24527 rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn),num=cell!==''&&cell!=null&&!isNaN(Number(cell))&&isFinite(Number(cell))&&/^[+\-]?\d/.test(String(cell));rx+=num?'<c r="'+ref+'"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});
24528 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
24529 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';
24530 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>';
24531 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><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" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
24532 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/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
24533 '_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>',
24534 '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>',
24535 '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>',
24536 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
24537 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'];
24538 var zparts=[],zcds=[],zoff=0,znf=0;
24539 order.forEach(function(name){
24540 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
24541 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]);
24542 var entry=new Uint8Array(lha.length+nb.length+sz);
24543 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
24544 zparts.push(entry);
24545 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));
24546 var cde=new Uint8Array(cda.length+nb.length);
24547 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
24548 zcds.push(cde);zoff+=entry.length;znf++;
24549 });
24550 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
24551 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]);
24552 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
24553 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
24554 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
24555 zout.set(new Uint8Array(ea),zpos);
24556 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
24557 }
24558
24559 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
24560 function getHistoryRows(){var r=[];document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){r.push([tr.getAttribute('data-timestamp')||'',tr.getAttribute('data-project')||'',tr.getAttribute('data-run')||'',tr.getAttribute('data-files')||'',tr.getAttribute('data-skipped')||'',tr.getAttribute('data-code')||'',tr.getAttribute('data-comments')||'',tr.getAttribute('data-blank')||'',tr.getAttribute('data-branch')||'',tr.getAttribute('data-commit')||'']);});return r;}
24561 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
24562 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
24563
24564 var csvBtn = document.getElementById('export-csv-btn');
24565 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
24566 var xlsBtn = document.getElementById('export-xls-btn');
24567 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
24568
24569 // ── Remaining CSP-safe event bindings ────────────────────────────────
24570 (function wireEvents() {
24571 var el;
24572 el = document.getElementById('reset-view-btn');
24573 if (el) el.addEventListener('click', window.resetView);
24574 el = document.getElementById('project-filter');
24575 if (el) el.addEventListener('input', window.applyFilters);
24576 el = document.getElementById('branch-filter');
24577 if (el) el.addEventListener('change', window.applyFilters);
24578 el = document.getElementById('per-page-sel');
24579 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
24580 el = document.getElementById('add-watched-btn');
24581 if (el) el.addEventListener('click', function() {
24582 fetch('/pick-directory?kind=reports')
24583 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
24584 .then(function(data) {
24585 if (!data.cancelled && data.selected_path) {
24586 var form = document.createElement('form');
24587 form.method = 'POST';
24588 form.action = '/watched-dirs/add';
24589 var ri = document.createElement('input');
24590 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
24591 var fi = document.createElement('input');
24592 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
24593 form.appendChild(ri); form.appendChild(fi);
24594 document.body.appendChild(form);
24595 form.submit();
24596 }
24597 })
24598 .catch(function(e) { alert('Could not open folder picker: ' + e); });
24599 });
24600 })();
24601
24602 (function randomizeWatermarks() {
24603 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
24604 if (!wms.length) return;
24605 var placed = [];
24606 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;}
24607 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];}
24608 var half=Math.floor(wms.length/2);
24609 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;});
24610 })();
24611
24612 (function spawnCodeParticles() {
24613 var container = document.getElementById('code-particles');
24614 if (!container) return;
24615 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'];
24616 for (var i = 0; i < 38; i++) {
24617 (function(idx) {
24618 var el = document.createElement('span');
24619 el.className = 'code-particle';
24620 el.textContent = snippets[idx % snippets.length];
24621 var left = Math.random() * 94 + 2;
24622 var top = Math.random() * 88 + 6;
24623 var dur = (Math.random() * 10 + 9).toFixed(1);
24624 var delay = (Math.random() * 18).toFixed(1);
24625 var rot = (Math.random() * 26 - 13).toFixed(1);
24626 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24627 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';
24628 container.appendChild(el);
24629 })(i);
24630 }
24631 })();
24632 })();
24633 </script>
24634 <script nonce="{{ csp_nonce }}">
24635 (function(){
24636 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'}];
24637 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);});}
24638 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24639 function init(){
24640 var btn=document.getElementById('settings-btn');if(!btn)return;
24641 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24642 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>';
24643 document.body.appendChild(m);
24644 var g=document.getElementById('scheme-grid');
24645 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);});
24646 var cl=document.getElementById('settings-close');
24647 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);
24648 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');});
24649 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24650 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24651 }
24652 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24653 }());
24654 </script>
24655 <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 }} — 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>
24656</body>
24657</html>
24658"##,
24659 ext = "html"
24660)]
24661struct HistoryTemplate {
24662 version: &'static str,
24663 entries: Vec<HistoryEntryRow>,
24664 total_scans: usize,
24665 linked_count: usize,
24666 browse_error: Option<String>,
24667 watched_dirs: Vec<String>,
24668 csp_nonce: String,
24669 server_mode: bool,
24670}
24671
24672#[derive(Template)]
24675#[template(
24676 source = r##"
24677<!doctype html>
24678<html lang="en">
24679<head>
24680 <meta charset="utf-8">
24681 <meta name="viewport" content="width=device-width, initial-scale=1">
24682 <title>OxideSLOC | Compare Scans</title>
24683 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24684 <style nonce="{{ csp_nonce }}">
24685 :root {
24686 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24687 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24688 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24689 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24690 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
24691 }
24692 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24693 *{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;}
24694 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24695 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24696 .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);}
24697 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24698 .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));}
24699 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24700 .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;}
24701 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24702 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24703 @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; } }
24704 .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;}
24705 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24706 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24707 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24708 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24709 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24710 .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;}
24711 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24712 .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);}
24713 .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;}
24714 .settings-close:hover{color:var(--text);background:var(--surface-2);}
24715 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24716 .settings-modal-body{padding:14px 16px 16px;}
24717 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24718 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24719 .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;}
24720 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24721 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24722 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24723 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24724 .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;}
24725 .tz-select:focus{border-color:var(--oxide);}
24726 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24727 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24728 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24729 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24730 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24731 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
24732 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
24733 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24734 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24735 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24736 .per-page-label{font-size:13px;color:var(--muted);}
24737 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;}
24738 .filter-input{min-width:180px;cursor:text;}
24739 .table-wrap{width:100%;overflow-x:auto;}
24740 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
24741 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;}
24742 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24743 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24744 #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;}
24745 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
24746 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
24747 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
24748 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
24749 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
24750 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
24751 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
24752 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
24753 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
24754 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24755 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24756 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24757 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24758 tr:last-child td{border-bottom:none;}
24759 tr.selected td{background:var(--sel-bg);}
24760 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
24761 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
24762 tr{cursor:pointer;}
24763 tr.row-locked{opacity:.35;cursor:not-allowed;}
24764 tr.row-locked td{pointer-events:none;}
24765 .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;}
24766 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
24767 .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;}
24768 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
24769 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
24770 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
24771 .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);}
24772 .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);}
24773 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24774 .metric-num{font-weight:700;color:var(--text);}
24775 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24776 .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;}
24777 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
24778 .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;}
24779 .btn:hover{background:var(--line);}
24780 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
24781 .btn.primary:hover{opacity:.9;}
24782 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
24783 .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;}
24784 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24785 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24786 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24787 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24788 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24789 .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;}
24790 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24791 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24792 .watched-chip-rm:hover{color:var(--oxide);}
24793 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24794 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24795 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24796 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24797 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
24798 .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;}
24799 .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;}
24800 .btn-back:hover{background:var(--line);}
24801 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24802 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24803 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24804 .pagination-info{font-size:13px;color:var(--muted);}
24805 .pagination-btns{display:flex;gap:6px;}
24806 .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;}
24807 .pg-btn:hover:not(:disabled){background:var(--line);}
24808 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24809 .pg-btn:disabled{opacity:.35;cursor:default;}
24810 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
24811 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24812 .site-footer a{color:var(--muted);}
24813 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24814 .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;}
24815 .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;}
24816 .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;}
24817 @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));}}
24818 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24819 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24820 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
24821 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24822 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24823 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24824 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
24825 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24826 .stat-chip:hover .stat-chip-tip{opacity:1;}
24827 .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;}
24828 .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;}
24829 .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%;}
24830 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
24831 .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;}
24832 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
24833 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
24834 .hidden{display:none!important;}
24835 .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%;}
24836 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
24837 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
24838 .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;}
24839 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
24840 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
24841 .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;}
24842 .scope-option:hover{background:var(--line);}
24843 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
24844 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
24845 .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;}
24846 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
24847 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
24848 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
24849 .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;}
24850 </style>
24851</head>
24852<body>
24853 <div class="background-watermarks" aria-hidden="true">
24854 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24855 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24856 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24857 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24858 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24859 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24860 </div>
24861 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24862 <div class="top-nav">
24863 <div class="top-nav-inner">
24864 <a class="brand" href="/">
24865 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24866 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
24867 </a>
24868 <div class="nav-right">
24869 <a class="nav-pill" href="/">Home</a>
24870 <div class="nav-dropdown">
24871 <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>
24872 <div class="nav-dropdown-menu">
24873 <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>
24874 </div>
24875 </div>
24876 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24877 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24878 <div class="nav-dropdown">
24879 <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>
24880 <div class="nav-dropdown-menu">
24881 <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>
24882 </div>
24883 </div>
24884 <div class="server-status-wrap" id="server-status-wrap">
24885 <div class="nav-pill server-online-pill" id="server-status-pill">
24886 <span class="status-dot" id="status-dot"></span>
24887 <span id="server-status-label">Server</span>
24888 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24889 </div>
24890 <div class="server-status-tip">
24891 OxideSLOC is running — accessible on your network.
24892 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24893 </div>
24894 </div>
24895 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24896 <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>
24897 </button>
24898 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24899 <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>
24900 <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>
24901 </button>
24902 </div>
24903 </div>
24904 </div>
24905
24906 <div class="page">
24907 <div class="watched-bar">
24908 <div class="watched-bar-left">
24909 <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>
24910 <span class="watched-label">Watched Folders</span>
24911 <div class="watched-chips">
24912 {% if server_mode %}
24913 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24914 {% else %}
24915 {% for dir in watched_dirs %}
24916 <span class="watched-chip">
24917 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24918 <form method="POST" action="/watched-dirs/remove" style="display:contents">
24919 <input type="hidden" name="folder_path" value="{{ dir }}">
24920 <input type="hidden" name="redirect_to" value="/compare-scans">
24921 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
24922 </form>
24923 </span>
24924 {% endfor %}
24925 {% if watched_dirs.is_empty() %}
24926 <span class="watched-none">No folders watched — click Choose to add one</span>
24927 {% endif %}
24928 {% endif %}
24929 </div>
24930 </div>
24931 {% if !server_mode %}
24932 <div class="watched-bar-right">
24933 <button type="button" class="btn" id="add-watched-btn">
24934 <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>
24935 Choose
24936 </button>
24937 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24938 <input type="hidden" name="redirect_to" value="/compare-scans">
24939 <button type="submit" class="btn">↻ Refresh</button>
24940 </form>
24941 </div>
24942 {% endif %}
24943 </div>
24944 {% if total_scans > 0 %}
24945 <div class="summary-strip">
24946 <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>
24947 <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>
24948 <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>
24949 <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>
24950 </div>
24951 {% endif %}
24952 <section class="panel">
24953 <div class="panel-header">
24954 <div>
24955 <h1>Compare Scans</h1>
24956 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
24957 </div>
24958 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
24959 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
24960 <button class="btn primary" id="compare-btn" disabled>
24961 <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>
24962 Compare <span class="sel-count" id="sel-count">0</span> Selected
24963 </button>
24964 </div>
24965 </div>
24966 </div>
24967
24968 {% if entries.is_empty() %}
24969 <div class="empty-state">
24970 <strong>No scans yet</strong>
24971 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.
24972 </div>
24973 {% else %}
24974 <div class="filter-row">
24975 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
24976 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24977 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
24978 </div>
24979 <div class="scope-panel hidden" id="scope-panel">
24980 <div class="scope-panel-label">
24981 <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>
24982 Compare scope — choose what to include
24983 </div>
24984 <div class="scope-options" id="scope-options"></div>
24985 </div>
24986 {% if total_scans > 0 %}
24987 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
24988 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
24989 <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>
24990 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
24991 </div>
24992 </div>
24993 {% endif %}
24994 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
24995 <span class="compare-all-label">
24996 <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>
24997 Quick Compare All
24998 </span>
24999 </div>
25000 <div class="table-wrap">
25001 <table id="compare-table">
25002 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
25003 <thead>
25004 <tr id="compare-thead">
25005 <th><div class="col-resize-handle"></div></th>
25006 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25007 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25008 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
25009 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25010 <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>
25011 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25012 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25013 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25014 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25015 <th>Submodules<div class="col-resize-handle"></div></th>
25016 </tr>
25017 </thead>
25018 <tbody id="compare-tbody">
25019 {% for entry in entries %}
25020 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
25021 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
25022 data-project="{{ entry.project_label }}"
25023 data-files="{{ entry.files_analyzed }}"
25024 data-code="{{ entry.code_lines }}"
25025 data-comments="{{ entry.comment_lines }}"
25026 data-blank="{{ entry.blank_lines }}"
25027 data-branch="{{ entry.git_branch }}"
25028 data-commit="{{ entry.git_commit }}"
25029 data-submodules="{{ entry.submodule_names_csv }}">
25030 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
25031 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
25032 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
25033 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
25034 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
25035 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
25036 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
25037 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
25038 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
25039 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
25040 <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>
25041 </tr>
25042 {% endfor %}
25043 </tbody>
25044 </table>
25045 </div>
25046 <div class="pagination">
25047 <span class="pagination-info" id="pagination-info"></span>
25048 <div class="pagination-btns" id="pagination-btns"></div>
25049 <div class="flex-row">
25050 <span class="per-page-label">Show</span>
25051 <select class="per-page" id="per-page-sel">
25052 <option value="10">10 per page</option>
25053 <option value="25" selected>25 per page</option>
25054 <option value="50">50 per page</option>
25055 <option value="100">100 per page</option>
25056 </select>
25057 <span class="per-page-label" id="page-range-label"></span>
25058 </div>
25059 </div>
25060 {% endif %}
25061 </section>
25062 </div>
25063
25064 <footer class="site-footer">
25065 local code analysis - metrics, history and reports
25066 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25067 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25068 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25069 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25070 · <a href="/api-docs" rel="noopener">REST API</a>
25071 </footer>
25072
25073 <script nonce="{{ csp_nonce }}">
25074 (function () {
25075 // ── Theme ──────────────────────────────────────────────────────────────
25076 var storageKey = 'oxide-sloc-theme';
25077 var body = document.body;
25078 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25079 var toggle = document.getElementById('theme-toggle');
25080 if (toggle) toggle.addEventListener('click', function () {
25081 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25082 body.classList.toggle('dark-theme', next === 'dark');
25083 try { localStorage.setItem(storageKey, next); } catch(e) {}
25084 });
25085
25086 // ── State ─────────────────────────────────────────────────────────────
25087 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
25088 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
25089 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
25090 window._allCompareRows = allRows;
25091
25092 // ── Stat chips ────────────────────────────────────────────────────────
25093 (function() {
25094 var projects = {}, latestTs = '', latestRow = null;
25095 allRows.forEach(function(r) {
25096 var p = r.dataset.project || ''; if (p) projects[p] = true;
25097 var ts = r.dataset.timestamp || '';
25098 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
25099 });
25100 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();}
25101 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>':'');}
25102 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
25103 if (latestRow) {
25104 setChipVal('agg-code', latestRow.dataset.code);
25105 setChipVal('agg-files', latestRow.dataset.files);
25106 }
25107 })();
25108
25109 // ── Branch filter population ──────────────────────────────────────────
25110 (function() {
25111 var branches = {};
25112 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
25113 var sel = document.getElementById('branch-filter');
25114 if (sel) Object.keys(branches).sort().forEach(function(b) {
25115 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
25116 });
25117 })();
25118
25119 // ── Filter ────────────────────────────────────────────────────────────
25120 function getFilteredRows() {
25121 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
25122 var branch = ((document.getElementById('branch-filter') || {}).value || '');
25123 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
25124 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
25125 if (branch && (r.dataset.branch || '') !== branch) return false;
25126 return true;
25127 });
25128 }
25129
25130 // ── Pagination ────────────────────────────────────────────────────────
25131 function renderPage() {
25132 var filtered = getFilteredRows();
25133 var total = filtered.length;
25134 var totalPages = Math.max(1, Math.ceil(total / perPage));
25135 currentPage = Math.min(currentPage, totalPages);
25136 var start = (currentPage - 1) * perPage;
25137 var end = Math.min(start + perPage, total);
25138 var shown = {};
25139 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
25140 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
25141 r.style.display = shown[r.dataset.run] ? '' : 'none';
25142 });
25143 var rl = document.getElementById('page-range-label');
25144 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
25145 var info = document.getElementById('pagination-info');
25146 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
25147 var btns = document.getElementById('pagination-btns');
25148 if (!btns) return;
25149 btns.innerHTML = '';
25150 function makeBtn(lbl, pg, active, disabled) {
25151 var b = document.createElement('button');
25152 b.className = 'pg-btn' + (active ? ' active' : '');
25153 b.textContent = lbl; b.disabled = disabled;
25154 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
25155 return b;
25156 }
25157 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
25158 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
25159 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
25160 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
25161 }
25162
25163 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
25164 window.applyFilters = function() { currentPage = 1; renderPage(); };
25165
25166 // ── Sorting ───────────────────────────────────────────────────────────
25167 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
25168 function doSort(col, type, order) {
25169 var tbody = document.getElementById('compare-tbody');
25170 if (!tbody) return;
25171 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25172 rows.sort(function(a, b) {
25173 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
25174 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
25175 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
25176 return va < vb ? 1 : va > vb ? -1 : 0;
25177 });
25178 rows.forEach(function(r) { tbody.appendChild(r); });
25179 currentPage = 1; renderPage();
25180 }
25181 sortHeaders.forEach(function(th) {
25182 th.addEventListener('click', function(e) {
25183 if (e.target.classList.contains('col-resize-handle')) return;
25184 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
25185 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
25186 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25187 th.classList.add('sort-' + sortOrder);
25188 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
25189 doSort(col, type, sortOrder);
25190 });
25191 });
25192
25193 // Apply default sort (timestamp desc) on initial load
25194 (function() {
25195 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
25196 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
25197 })();
25198
25199 // ── Column resize ─────────────────────────────────────────────────────
25200 (function() {
25201 var table = document.getElementById('compare-table');
25202 if (!table) return;
25203 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
25204 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
25205 ths.forEach(function(th, i) {
25206 var handle = th.querySelector('.col-resize-handle');
25207 if (!handle || !cols[i]) return;
25208 var startX, startW;
25209 handle.addEventListener('mousedown', function(e) {
25210 e.stopPropagation(); e.preventDefault();
25211 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
25212 handle.classList.add('dragging');
25213 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
25214 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
25215 document.addEventListener('mousemove', onMove);
25216 document.addEventListener('mouseup', onUp);
25217 });
25218 });
25219 })();
25220
25221 // ── Reset view ────────────────────────────────────────────────────────
25222 window.resetView = function() {
25223 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
25224 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
25225 sortCol = null; sortOrder = 'asc';
25226 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25227 var tbody = document.getElementById('compare-tbody');
25228 if (tbody) {
25229 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25230 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
25231 rows.forEach(function(r) { tbody.appendChild(r); });
25232 }
25233 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
25234 var table = document.getElementById('compare-table');
25235 currentPage = 1; renderPage();
25236 currentPage = 1; renderPage();
25237 };
25238
25239 renderPage();
25240 buildCompareAllBar();
25241
25242 // ── Row selection state ───────────────────────────────────────────────
25243 var selected = [];
25244 var lockedProject = null; // project label of first selected scan
25245
25246 function updateCompareBtn() {
25247 var btn = document.getElementById('compare-btn');
25248 var cnt = document.getElementById('sel-count');
25249 if (!btn) return;
25250 btn.disabled = selected.length < 2;
25251 if (cnt) cnt.textContent = selected.length;
25252 }
25253
25254 function applyProjectLock() {
25255 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25256 allRows.forEach(function(r) {
25257 if (lockedProject === null) {
25258 r.classList.remove('row-locked');
25259 } else {
25260 var proj = r.dataset.project || '';
25261 if (proj !== lockedProject) {
25262 r.classList.add('row-locked');
25263 } else {
25264 r.classList.remove('row-locked');
25265 }
25266 }
25267 });
25268 }
25269
25270 function toggleRow(row) {
25271 if (row.classList.contains('row-locked')) return;
25272 var vid = row.dataset.vid || row.dataset.run;
25273 var idx = selected.indexOf(vid);
25274 if (idx >= 0) {
25275 selected.splice(idx, 1);
25276 row.classList.remove('selected');
25277 var b = document.getElementById('badge-' + vid);
25278 if (b) b.textContent = '';
25279 // Release project lock if nothing selected
25280 if (selected.length === 0) lockedProject = null;
25281 } else {
25282 // Set project lock on first selection
25283 if (selected.length === 0) lockedProject = row.dataset.project || null;
25284 selected.push(vid);
25285 row.classList.add('selected');
25286 }
25287 selected.forEach(function(v, i) {
25288 var b = document.getElementById('badge-' + v);
25289 if (b) b.textContent = i + 1;
25290 });
25291 applyProjectLock();
25292 updateCompareBtn();
25293 buildScopePanel();
25294 }
25295
25296 // ── Compare-All bar ───────────────────────────────────────────────────
25297 function buildCompareAllBar() {
25298 var bar = document.getElementById('compare-all-bar');
25299 if (!bar) return;
25300 // Group all rows by project label.
25301 var groups = {};
25302 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25303 // Use all rows from the source data (not just visible).
25304 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25305 // We need ALL rows across all pages, not just the rendered ones.
25306 // Use the underlying allRows array that the pagination JS also uses.
25307 var sourceRows = window._allCompareRows || allRowsAll;
25308 sourceRows.forEach(function(r) {
25309 var proj = r.dataset.project || '';
25310 var vid = r.dataset.vid || r.dataset.run || '';
25311 if (!proj || !vid) return;
25312 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
25313 groups[proj].ids.push(vid);
25314 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
25315 });
25316 // Build buttons for each project with >= 2 scans.
25317 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
25318 if (!keys.length) { bar.style.display = 'none'; return; }
25319 bar.style.display = 'flex';
25320 // Remove old buttons (keep label).
25321 var oldBtns = bar.querySelectorAll('.compare-all-btn');
25322 oldBtns.forEach(function(b) { b.remove(); });
25323 keys.sort();
25324 keys.forEach(function(proj) {
25325 var g = groups[proj];
25326 var btn = document.createElement('button');
25327 btn.className = 'compare-all-btn';
25328 btn.type = 'button';
25329 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
25330 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
25331 btn.addEventListener('click', function() {
25332 // Sort ids by timestamp (ascending).
25333 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
25334 pairs.sort(function(a, b) { return a.ts - b.ts; });
25335 var sorted = pairs.map(function(p) { return p.id; });
25336 if (sorted.length === 2) {
25337 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
25338 } else {
25339 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
25340 }
25341 });
25342 bar.appendChild(btn);
25343 });
25344 }
25345
25346 // ── Scope panel ───────────────────────────────────────────────────────
25347 var selectedScope = 'all';
25348
25349 function buildScopePanel() {
25350 var panel = document.getElementById('scope-panel');
25351 var opts = document.getElementById('scope-options');
25352 if (!panel || !opts) return;
25353 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25354
25355 // Collect union of submodules from all selected rows.
25356 var allSubs = {};
25357 selected.forEach(function(vid) {
25358 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
25359 if (!row) return;
25360 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
25361 });
25362 var subList = Object.keys(allSubs).sort();
25363 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25364
25365 panel.classList.remove('hidden');
25366 opts.innerHTML = '';
25367
25368 function makeOption(value, label, title) {
25369 var div = document.createElement('div');
25370 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
25371 div.dataset.scopeValue = value;
25372 if (title) div.title = title;
25373 var radio = document.createElement('span');
25374 radio.className = 'scope-option-radio';
25375 var lbl = document.createElement('span');
25376 lbl.textContent = label;
25377 div.appendChild(radio);
25378 div.appendChild(lbl);
25379 div.addEventListener('click', function() {
25380 selectedScope = value;
25381 opts.querySelectorAll('.scope-option').forEach(function(o) {
25382 o.classList.toggle('selected', o.dataset.scopeValue === value);
25383 });
25384 });
25385 return div;
25386 }
25387
25388 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
25389 var sep = document.createElement('span');
25390 sep.className = 'scope-option-sep';
25391 opts.appendChild(sep);
25392 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
25393 subList.forEach(function(s) {
25394 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
25395 });
25396 }
25397
25398 function doCompare() {
25399 if (selected.length < 2) return;
25400 if (selected.length === 2) {
25401 // Two-scan delta (existing flow with scope support).
25402 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
25403 if (selectedScope === 'super') url += '&scope=super';
25404 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25405 window.location.href = url;
25406 } else {
25407 // Multi-scan timeline (N >= 3) — pass scope params too.
25408 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
25409 if (selectedScope === 'super') url += '&scope=super';
25410 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25411 window.location.href = url;
25412 }
25413 }
25414
25415 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
25416 var cbtn = document.getElementById('compare-btn');
25417 if (cbtn) cbtn.addEventListener('click', doCompare);
25418 var pfEl = document.getElementById('project-filter');
25419 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
25420 var bfEl = document.getElementById('branch-filter');
25421 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
25422 var rvBtn = document.getElementById('reset-view-btn');
25423 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
25424 var ppSel = document.getElementById('per-page-sel');
25425 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
25426
25427 var cmpTbody = document.getElementById('compare-tbody');
25428 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
25429 var row = e.target.closest('.compare-row');
25430 if (row) toggleRow(row);
25431 });
25432
25433 (function randomizeWatermarks() {
25434 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25435 if (!wms.length) return;
25436 var placed = [];
25437 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;}
25438 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];}
25439 var half=Math.floor(wms.length/2);
25440 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;});
25441 })();
25442
25443 (function spawnCodeParticles() {
25444 var container = document.getElementById('code-particles');
25445 if (!container) return;
25446 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'];
25447 for (var i = 0; i < 38; i++) {
25448 (function(idx) {
25449 var el = document.createElement('span');
25450 el.className = 'code-particle';
25451 el.textContent = snippets[idx % snippets.length];
25452 var left = Math.random() * 94 + 2;
25453 var top = Math.random() * 88 + 6;
25454 var dur = (Math.random() * 10 + 9).toFixed(1);
25455 var delay = (Math.random() * 18).toFixed(1);
25456 var rot = (Math.random() * 26 - 13).toFixed(1);
25457 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25458 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';
25459 container.appendChild(el);
25460 })(i);
25461 }
25462 })();
25463
25464 // ── Watched folder picker ─────────────────────────────────────────────
25465 (function() {
25466 var btn = document.getElementById('add-watched-btn');
25467 if (!btn) return;
25468 btn.addEventListener('click', function() {
25469 fetch('/pick-directory?kind=reports')
25470 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
25471 .then(function(data) {
25472 if (!data.cancelled && data.selected_path) {
25473 var form = document.createElement('form');
25474 form.method = 'POST';
25475 form.action = '/watched-dirs/add';
25476 var ri = document.createElement('input');
25477 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
25478 var fi = document.createElement('input');
25479 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
25480 form.appendChild(ri); form.appendChild(fi);
25481 document.body.appendChild(form);
25482 form.submit();
25483 }
25484 })
25485 .catch(function(e) { alert('Could not open folder picker: ' + e); });
25486 });
25487 })();
25488
25489 // ── Submodule chip truncation ─────────────────────────────────────────
25490 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
25491 var chips = cell.querySelectorAll('.submod-chip');
25492 var MAX = 4;
25493 if (chips.length <= MAX) return;
25494 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
25495 var badge = document.createElement('span');
25496 badge.className = 'submod-overflow-badge';
25497 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
25498 badge.textContent = '+' + (chips.length - MAX) + ' more';
25499 cell.appendChild(badge);
25500 cell.style.maxHeight = 'none';
25501 });
25502 })();
25503 </script>
25504 <script nonce="{{ csp_nonce }}">
25505 (function(){
25506 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'}];
25507 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);});}
25508 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25509 function init(){
25510 var btn=document.getElementById('settings-btn');if(!btn)return;
25511 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25512 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>';
25513 document.body.appendChild(m);
25514 var g=document.getElementById('scheme-grid');
25515 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);});
25516 var cl=document.getElementById('settings-close');
25517 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);
25518 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');});
25519 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25520 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25521 }
25522 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25523 }());
25524 </script>
25525 <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]';
25526 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;}
25527 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>
25528</body>
25529</html>
25530"##,
25531 ext = "html"
25532)]
25533struct CompareSelectTemplate {
25534 version: &'static str,
25535 entries: Vec<HistoryEntryRow>,
25536 total_scans: usize,
25537 watched_dirs: Vec<String>,
25538 csp_nonce: String,
25539 server_mode: bool,
25540}
25541
25542#[derive(Template)]
25545#[template(
25546 source = r##"
25547<!doctype html>
25548<html lang="en">
25549<head>
25550 <meta charset="utf-8">
25551 <meta name="viewport" content="width=device-width, initial-scale=1">
25552 <title>OxideSLOC | Scan Delta</title>
25553 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25554 <style nonce="{{ csp_nonce }}">
25555 :root {
25556 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
25557 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
25558 --nav:#283790; --nav-2:#013e6b;
25559 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
25560 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
25561 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
25562 }
25563 body.dark-theme {
25564 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
25565 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
25566 }
25567 *{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;}
25568 .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);}
25569 .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;}
25570 .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));}
25571 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25572 .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;}
25573 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
25574 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25575 @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; } }
25576 .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;}
25577 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25578 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25579 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25580 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25581 .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;}
25582 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25583 .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);}
25584 .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;}
25585 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25586 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25587 .settings-modal-body{padding:14px 16px 16px;}
25588 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25589 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25590 .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;}
25591 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25592 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25593 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25594 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25595 .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;}
25596 .tz-select:focus{border-color:var(--oxide);}
25597 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
25598 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25599 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
25600 .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;}
25601 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
25602 .hero-body{display:block;}
25603 .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;}
25604 .btn-back:hover{background:var(--line);}
25605 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
25606 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
25607 .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;}
25608 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
25609 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;}
25610 .muted{color:var(--muted);font-size:14px;}
25611 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
25612 .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;}
25613 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
25614 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
25615 .vpill-arrow{font-size:20px;color:var(--muted);}
25616 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
25617 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
25618 .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;}
25619 .delta-card.delta-card-wide{padding:22px 24px;}
25620 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
25621 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
25622 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
25623 .delta-card-from{font-size:15px;color:var(--muted);}
25624 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
25625 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
25626 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
25627 .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%;}
25628 .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;}
25629 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
25630 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
25631 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
25632 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
25633 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
25634 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
25635 .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;}
25636 .meta-card-commit:hover{color:var(--oxide);}
25637 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
25638 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
25639 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
25640 .meta-value{color:var(--text);font-size:13px;}
25641 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
25642 .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;}
25643 .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);}
25644 .delta-card:hover .dc-tip{display:block;}
25645 .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;}
25646 .export-btn:hover{background:var(--line);}
25647 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
25648 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
25649 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
25650 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
25651 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
25652 .delta-card-change.zero{color:var(--muted);background:transparent;}
25653 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
25654 .delta-card-pct.pos{color:var(--pos);}
25655 .delta-card-pct.neg{color:var(--neg);}
25656 .delta-card-pct.zero{color:var(--muted);}
25657 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
25658 .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;}
25659 .insight-card.insight-flag{border-color:var(--oxide);}
25660 .insight-card:hover .dc-tip{display:block;}
25661 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
25662 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
25663 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
25664 .insight-label.flag{color:var(--oxide);}
25665 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
25666 .insight-val.pos{color:var(--pos);}
25667 .insight-val.neg{color:var(--neg);}
25668 .insight-val.high{color:#c0392a;}
25669 .insight-val.med{color:#926000;}
25670 .insight-val.low{color:var(--pos);}
25671 body.dark-theme .insight-val.high{color:#ff6b6b;}
25672 body.dark-theme .insight-val.med{color:#f0c060;}
25673 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
25674 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
25675 .fc-row{display:flex;align-items:center;gap:8px;}
25676 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
25677 .fc-label{color:var(--muted);}
25678 .fc-modified .fc-count{color:#926000;}
25679 .fc-added .fc-count{color:var(--pos);}
25680 .fc-removed .fc-count{color:var(--neg);}
25681 .fc-unchanged .fc-count{color:var(--muted);}
25682 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
25683 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
25684 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
25685 .chip.modified{background:#fff2d8;color:#926000;}
25686 .chip.added{background:#e8f5ed;color:#1a8f47;}
25687 .chip.removed{background:#fdeaea;color:#b33b3b;}
25688 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
25689 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
25690 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
25691 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
25692 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
25693 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
25694 .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;}
25695 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
25696 .tab-btn:hover:not(.active){background:var(--line);}
25697 .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;}
25698 .btn-reset:hover{background:var(--line);}
25699 .table-wrap{width:100%;overflow-x:auto;}
25700 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
25701 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);}
25702 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
25703 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
25704 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
25705 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
25706 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
25707 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
25708 tr:last-child td{border-bottom:none;}
25709 tr:hover td{background:var(--surface-2);}
25710 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
25711 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
25712 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
25713 tr.row-added td{background:rgba(26,143,71,0.04);}
25714 tr.row-removed td{background:rgba(179,59,59,0.06);}
25715 tr.row-modified td{background:rgba(146,96,0,0.04);}
25716 tr.row-unchanged td{color:var(--muted);}
25717 tr.row-unchanged .status-badge{opacity:.65;}
25718 .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;}
25719 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
25720 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
25721 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
25722 .status-badge.modified{background:#fff2d8;color:#926000;}
25723 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
25724 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
25725 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
25726 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
25727 .delta-val{font-weight:700;}
25728 .delta-val.pos{color:var(--pos);}
25729 .delta-val.neg{color:var(--neg);}
25730 .delta-val.zero{color:var(--muted);}
25731 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
25732 .from-to strong{color:var(--text);font-weight:700;}
25733 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
25734 .from-to .ft-absent{color:var(--muted);font-weight:600;}
25735 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25736 .site-footer a{color:var(--muted);}
25737 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;}
25738 body.pdf-mode{background:#fff!important;}
25739 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
25740 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
25741 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25742 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25743 .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;}
25744 .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;}
25745 .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;}
25746 @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));}}
25747 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
25748 .path-link:hover{color:var(--oxide-2);}
25749 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
25750 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
25751 a.vpill-id:hover{color:var(--oxide);}
25752 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
25753 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
25754 .pagination-info{font-size:13px;color:var(--muted);}
25755 .pagination-btns{display:flex;gap:6px;}
25756 .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;}
25757 .pg-btn:hover:not(:disabled){background:var(--line);}
25758 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25759 .pg-btn:disabled{opacity:.35;cursor:default;}
25760 .per-page-label{font-size:13px;color:var(--muted);}
25761 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;}
25762 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25763 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
25764 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
25765 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
25766 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
25767 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
25768 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
25769 .tab-btn.tab-unchanged{color:var(--muted);}
25770 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
25771 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
25772 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
25773 .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;}
25774 .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;}
25775 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
25776 .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;}
25777 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
25778 .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;}
25779 .submod-scope-btn:hover{background:var(--line);}
25780 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25781 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
25782 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
25783 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
25784 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
25785 body.dark-theme .ic-card{background:var(--surface-2);}
25786 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
25787 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
25788 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
25789 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
25790 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
25791 .ic-cb{cursor:pointer;transition:filter .15s;}.ic-cb:hover{filter:brightness(1.12);}
25792 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
25793 .ic-card-h2-row .ic-card-h2{margin:0;}
25794 .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;}
25795 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25796 .chart-metric-btn:hover:not(.active){background:var(--line);}
25797 .chart-wrap{width:100%;overflow-x:auto;}
25798 #cmp-tl-svg{display:block;width:100%;}
25799 .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);}
25800 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
25801 #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;}
25802 </style>
25803</head>
25804<body>
25805 <div class="background-watermarks" aria-hidden="true">
25806 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25807 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25808 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25809 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25810 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812 </div>
25813 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25814 <div class="top-nav">
25815 <div class="top-nav-inner">
25816 <a class="brand" href="/">
25817 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25818 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
25819 </a>
25820 <div class="nav-right">
25821 <a class="nav-pill" href="/">Home</a>
25822 <div class="nav-dropdown">
25823 <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>
25824 <div class="nav-dropdown-menu">
25825 <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>
25826 </div>
25827 </div>
25828 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25829 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25830 <div class="nav-dropdown">
25831 <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>
25832 <div class="nav-dropdown-menu">
25833 <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>
25834 </div>
25835 </div>
25836 <div class="server-status-wrap" id="server-status-wrap">
25837 <div class="nav-pill server-online-pill" id="server-status-pill">
25838 <span class="status-dot" id="status-dot"></span>
25839 <span id="server-status-label">Server</span>
25840 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25841 </div>
25842 <div class="server-status-tip">
25843 OxideSLOC is running — accessible on your network.
25844 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25845 </div>
25846 </div>
25847 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25848 <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>
25849 </button>
25850 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25851 <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>
25852 <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>
25853 </button>
25854 </div>
25855 </div>
25856 </div>
25857
25858 <div class="page">
25859 <section class="hero">
25860 <div class="hero-header">
25861 <div>
25862 <h1 class="delta-title">Scan Delta</h1>
25863 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
25864 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
25865 {% if let Some(sub) = active_submodule %}
25866 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
25867 {% else if super_scope_active %}
25868 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
25869 {% else %}
25870 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
25871 {% endif %}
25872 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
25873 </div>
25874 </div>
25875 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
25876 <a class="btn-back" href="/compare-scans">
25877 <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>
25878 Compare Scans
25879 </a>
25880 <div class="export-group" style="margin-top:12px;">
25881 <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>
25882 <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>
25883 </div>
25884 </div>
25885 </div>
25886 {% if has_any_submodule_data %}
25887 <div class="submod-scope-bar">
25888 <span class="submod-scope-label">
25889 <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>
25890 Scope:
25891 </span>
25892 <div class="submod-scope-divider"></div>
25893 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
25894 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
25895 title="All files — super-repo and all submodules combined">Full scan</a>
25896 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
25897 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
25898 title="Only files that are not part of any submodule">Super-repo only</a>
25899 {% for sub in submodule_options %}
25900 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
25901 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
25902 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
25903 {% endfor %}
25904 </div>
25905 {% endif %}
25906 <div class="hero-body">
25907 <div class="meta-strip">
25908 <div class="delta-card delta-card-meta">
25909 <div class="meta-card-header">
25910 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
25911 <div class="meta-card-project-col">
25912 <div class="meta-card-project">{{ project_name }}</div>
25913 {% if has_any_submodule_data %}
25914 {% if let Some(sub) = active_submodule %}
25915 <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>
25916 {% else if super_scope_active %}
25917 <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>
25918 {% else %}
25919 <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>
25920 {% endif %}
25921 {% endif %}
25922 </div>
25923 </div>
25924 {% if !baseline_git_commit.is_empty() %}
25925 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
25926 {% else %}
25927 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
25928 {% endif %}
25929 <div class="meta-card-rows">
25930 <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>
25931 <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>
25932 <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>
25933 <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>
25934 {% if let Some(tags) = baseline_git_tags %}
25935 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25936 {% endif %}
25937 </div>
25938 </div>
25939 <div class="delta-card delta-card-meta">
25940 <div class="meta-card-header">
25941 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
25942 <div class="meta-card-project-col">
25943 <div class="meta-card-project">{{ project_name }}</div>
25944 {% if has_any_submodule_data %}
25945 {% if let Some(sub) = active_submodule %}
25946 <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>
25947 {% else if super_scope_active %}
25948 <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>
25949 {% else %}
25950 <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>
25951 {% endif %}
25952 {% endif %}
25953 </div>
25954 </div>
25955 {% if !current_git_commit.is_empty() %}
25956 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
25957 {% else %}
25958 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
25959 {% endif %}
25960 <div class="meta-card-rows">
25961 <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>
25962 <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>
25963 <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>
25964 <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>
25965 {% if let Some(tags) = current_git_tags %}
25966 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25967 {% endif %}
25968 </div>
25969 </div>
25970 </div>
25971 <div class="delta-strip">
25972 <div class="delta-card">
25973 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
25974 <div class="delta-card-label">Code lines</div>
25975 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
25976 <div class="delta-card-to">{{ current_code_fmt }}</div>
25977 {% 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>
25978 {% 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>
25979 {% else %}<div class="delta-card-pct zero">±0%</div>
25980 {% endif %}
25981 </div>
25982 <div class="delta-card">
25983 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
25984 <div class="delta-card-label">Files analyzed</div>
25985 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
25986 <div class="delta-card-to">{{ current_files_fmt }}</div>
25987 {% 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>
25988 {% 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>
25989 {% else %}<div class="delta-card-pct zero">±0%</div>
25990 {% endif %}
25991 </div>
25992 <div class="delta-card">
25993 <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>
25994 <div class="delta-card-label">Comment lines</div>
25995 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
25996 <div class="delta-card-to">{{ current_comments_fmt }}</div>
25997 {% 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>
25998 {% 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>
25999 {% else %}<div class="delta-card-pct zero">±0%</div>
26000 {% endif %}
26001 </div>
26002 {{ coverage_delta_card|safe }}
26003 <div class="delta-card delta-card-wide">
26004 <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>
26005 <div class="delta-card-label">File changes</div>
26006 <div class="file-changes-grid">
26007 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
26008 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
26009 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
26010 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
26011 </div>
26012 </div>
26013 </div>
26014 <div class="insights-panel">
26015 <div class="insight-card">
26016 <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>
26017 <div class="insight-label">Lines Added</div>
26018 <div class="insight-val pos">+{{ code_lines_added }}</div>
26019 <div class="insight-sub">New or grown source lines</div>
26020 </div>
26021 <div class="insight-card">
26022 <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>
26023 <div class="insight-label">Lines Removed</div>
26024 <div class="insight-val neg">−{{ code_lines_removed }}</div>
26025 <div class="insight-sub">Deleted or shrunk source lines</div>
26026 </div>
26027 <div class="insight-card">
26028 <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>
26029 <div class="insight-label">Churn Rate</div>
26030 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
26031 <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>
26032 </div>
26033 {% if scope_flag %}
26034 <div class="insight-card insight-flag">
26035 <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>
26036 <div class="insight-label flag">Scope Signal</div>
26037 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
26038 <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>
26039 </div>
26040 {% endif %}
26041 </div>
26042 </div>
26043 </section>
26044
26045 <section class="panel" id="inline-charts-section">
26046 <div class="panel-title">Scan Delta Charts</div>
26047 <div class="ic-grid">
26048 <div class="ic-card" style="grid-column:span 2">
26049 <div class="ic-card-h2-row">
26050 <span class="ic-card-h2">Timeline</span>
26051 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
26052 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
26053 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
26054 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
26055 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
26056 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
26057 </div>
26058 </div>
26059 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
26060 </div>
26061 <div class="ic-card">
26062 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
26063 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = before)</span></div>
26064 <div id="ic-c1"></div>
26065 </div>
26066 <div class="ic-card" id="ic-lang-card">
26067 <div class="ic-card-h2">Language Code Delta</div>
26068 <div id="ic-c3"></div>
26069 </div>
26070 <div class="ic-card">
26071 <div class="ic-card-h2">Delta by Metric</div>
26072 <div id="ic-c2"></div>
26073 </div>
26074 <div class="ic-card">
26075 <div class="ic-card-h2">File Change Distribution</div>
26076 <div id="ic-c4"></div>
26077 </div>
26078 </div>
26079 </section>
26080
26081 <section class="panel">
26082 <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 }} files</span></div>
26083 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
26084 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
26085 <button class="tab-btn tab-all active" data-filter="all">All ({{ files_modified + files_added + files_removed + files_unchanged }})</button>
26086 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
26087 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
26088 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
26089 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
26090 </div>
26091 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
26092 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
26093 <div class="export-group">
26094 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
26095 <button type="button" class="export-btn" id="delta-csv-btn">
26096 <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>
26097 CSV
26098 </button>
26099 <button type="button" class="export-btn" id="delta-xls-btn">
26100 <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>
26101 Excel
26102 </button>
26103 </div>
26104 </div>
26105 </div>
26106
26107 <div class="table-wrap">
26108 <table id="delta-table">
26109 <colgroup>
26110 <col>
26111 <col>
26112 <col>
26113 <col>
26114 <col>
26115 <col>
26116 <col>
26117 </colgroup>
26118 <thead>
26119 <tr id="delta-thead">
26120 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26121 <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>
26122 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26123 <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>
26124 <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>
26125 <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>
26126 <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>
26127 </tr>
26128 </thead>
26129 <tbody id="delta-tbody">
26130 {% for row in file_rows %}
26131 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
26132 data-path="{{ row.relative_path }}"
26133 data-language="{{ row.language }}"
26134 data-baseline-code="{{ row.baseline_code }}"
26135 data-current-code="{{ row.current_code }}"
26136 data-code-delta="{{ row.code_delta_str }}"
26137 data-comment-delta="{{ row.comment_delta_str }}"
26138 data-total-delta="{{ row.total_delta_str }}"
26139 data-orig-idx="">
26140 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
26141 <td class="hide-sm">{{ row.language }}</td>
26142 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
26143 <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>
26144 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
26145 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
26146 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
26147 </tr>
26148 {% endfor %}
26149 </tbody>
26150 </table>
26151 </div>
26152 <div class="pagination">
26153 <span class="pagination-info" id="pg-range-label"></span>
26154 <div class="pagination-btns" id="pg-btns"></div>
26155 <div class="flex-row">
26156 <span class="per-page-label">Show</span>
26157 <select class="per-page" id="per-page-sel">
26158 <option value="10">10 per page</option>
26159 <option value="25" selected>25 per page</option>
26160 <option value="50">50 per page</option>
26161 <option value="100">100 per page</option>
26162 </select>
26163 </div>
26164 </div>
26165 </section>
26166 </div>
26167
26168 <div id="ic-tt"></div>
26169
26170 <footer class="site-footer">
26171 local code analysis - metrics, history and reports
26172 · <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>
26173 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26174 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26175 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26176 · <a href="/api-docs" rel="noopener">REST API</a>
26177 </footer>
26178
26179 <script nonce="{{ csp_nonce }}">
26180 (function () {
26181 var storageKey = 'oxide-sloc-theme';
26182 var body = document.body;
26183 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26184 var toggle = document.getElementById('theme-toggle');
26185 if (toggle) toggle.addEventListener('click', function () {
26186 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26187 body.classList.toggle('dark-theme', next === 'dark');
26188 try { localStorage.setItem(storageKey, next); } catch(e) {}
26189 });
26190
26191 (function randomizeWatermarks() {
26192 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26193 if (!wms.length) return;
26194 var placed = [];
26195 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;}
26196 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];}
26197 var half=Math.floor(wms.length/2);
26198 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;});
26199 })();
26200
26201 (function spawnCodeParticles() {
26202 var container = document.getElementById('code-particles');
26203 if (!container) return;
26204 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'];
26205 for (var i = 0; i < 38; i++) {
26206 (function(idx) {
26207 var el = document.createElement('span');
26208 el.className = 'code-particle';
26209 el.textContent = snippets[idx % snippets.length];
26210 var left = Math.random() * 94 + 2;
26211 var top = Math.random() * 88 + 6;
26212 var dur = (Math.random() * 10 + 9).toFixed(1);
26213 var delay = (Math.random() * 18).toFixed(1);
26214 var rot = (Math.random() * 26 - 13).toFixed(1);
26215 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26216 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';
26217 container.appendChild(el);
26218 })(i);
26219 }
26220 })();
26221 })();
26222
26223 var activeStatusFilter = 'all';
26224 var deltaPerPage = 25, deltaCurrPage = 1;
26225
26226 function openFolder(path) {
26227 fetch('/open-path?path=' + encodeURIComponent(path))
26228 .then(function (r) { return r.json(); })
26229 .then(function (d) {
26230 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
26231 })
26232 .catch(function () {});
26233 }
26234
26235 function getDeltaFilteredRows() {
26236 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
26237 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
26238 });
26239 }
26240
26241 function renderDeltaPage() {
26242 var filtered = getDeltaFilteredRows();
26243 var total = filtered.length;
26244 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
26245 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
26246 var start = (deltaCurrPage - 1) * deltaPerPage;
26247 var end = Math.min(start + deltaPerPage, total);
26248 var shownSet = {};
26249 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
26250 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
26251 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
26252 });
26253 var rl = document.getElementById('pg-range-label');
26254 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total + ' files' : 'No results';
26255 var btns = document.getElementById('pg-btns');
26256 if (!btns) return;
26257 btns.innerHTML = '';
26258 if (totalPages <= 1) return;
26259 function makeBtn(lbl, pg, active, disabled) {
26260 var b = document.createElement('button');
26261 b.className = 'pg-btn' + (active ? ' active' : '');
26262 b.textContent = lbl; b.disabled = disabled;
26263 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
26264 return b;
26265 }
26266 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
26267 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26268 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
26269 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
26270 }
26271
26272 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
26273
26274 function filterRows(status, btn) {
26275 activeStatusFilter = status;
26276 deltaCurrPage = 1;
26277 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
26278 b.classList.remove('active');
26279 });
26280 if (btn) btn.classList.add('active');
26281 renderDeltaPage();
26282 }
26283
26284 // ── Sorting ──────────────────────────────────────────────────────────────
26285 var sortCol = null, sortOrder = 'asc';
26286 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
26287 (function() {
26288 var tbody = document.getElementById('delta-tbody');
26289 if (!tbody) return;
26290 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26291 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
26292 })();
26293
26294 function parseDeltaNum(str) {
26295 if (!str || str === '\u2014') return 0;
26296 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
26297 }
26298
26299 sortHeaders.forEach(function(th) {
26300 th.addEventListener('click', function(e) {
26301 if (e.target.classList.contains('col-resize-handle')) return;
26302 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26303 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26304 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26305 th.classList.add('sort-' + sortOrder);
26306 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
26307 var tbody = document.getElementById('delta-tbody');
26308 if (!tbody) return;
26309 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26310 rows.sort(function(a, b) {
26311 var va, vb;
26312 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
26313 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
26314 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
26315 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
26316 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26317 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26318 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26319 else { va = ''; vb = ''; }
26320 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26321 return va < vb ? 1 : va > vb ? -1 : 0;
26322 });
26323 rows.forEach(function(r) { tbody.appendChild(r); });
26324 deltaCurrPage = 1;
26325 renderDeltaPage();
26326 var activeBtn = document.querySelector('.tab-btn.active');
26327 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26328 if (activeBtn) activeBtn.classList.add('active');
26329 });
26330 });
26331
26332 // ── Column resize ─────────────────────────────────────────────────────────
26333 (function() {
26334 var table = document.getElementById('delta-table');
26335 if (!table) return;
26336 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26337 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
26338 ths.forEach(function(th, i) {
26339 var handle = th.querySelector('.col-resize-handle');
26340 if (!handle || !cols[i]) return;
26341 var startX, startW;
26342 handle.addEventListener('mousedown', function(e) {
26343 e.stopPropagation(); e.preventDefault();
26344 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26345 handle.classList.add('dragging');
26346 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26347 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26348 document.addEventListener('mousemove', onMove);
26349 document.addEventListener('mouseup', onUp);
26350 });
26351 });
26352 })();
26353
26354 // ── Reset ─────────────────────────────────────────────────────────────────
26355 window.resetDeltaTable = function() {
26356 sortCol = null; sortOrder = 'asc';
26357 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26358 var tbody = document.getElementById('delta-tbody');
26359 if (tbody) {
26360 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26361 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26362 rows.forEach(function(r) { tbody.appendChild(r); });
26363 }
26364 var table = document.getElementById('delta-table');
26365 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26366 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
26367 activeStatusFilter = 'all';
26368 deltaCurrPage = 1;
26369 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26370 var allBtn = document.querySelector('.tab-btn');
26371 if (allBtn) allBtn.classList.add('active');
26372 renderDeltaPage();
26373 };
26374
26375 renderDeltaPage();
26376
26377 // Compact number formatter (shared by the delta table; charts define their own locally)
26378 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();}
26379 function fmtFull(n){return Number(n).toLocaleString();}
26380
26381 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
26382 function fmtFromTo() {
26383 var tbody = document.getElementById('delta-tbody');
26384 if (!tbody) return;
26385 tbody.querySelectorAll('.delta-row').forEach(function(row) {
26386 var status = row.dataset.status || '';
26387 var ft = row.querySelector('.from-to');
26388 if (!ft) return;
26389 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
26390 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
26391 var strongs = ft.querySelectorAll('strong');
26392 // Apply fmt() to non-absent strong values
26393 strongs.forEach(function(el) {
26394 var n = parseInt(el.textContent, 10);
26395 if (!isNaN(n)) el.textContent = fmt(n);
26396 });
26397 // Safety: force dash for genuinely absent sides
26398 if (status === 'added' && bv === 0) {
26399 var bs = ft.querySelector('strong:first-of-type');
26400 if (bs && bs.textContent === '0') {
26401 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
26402 }
26403 }
26404 if (status === 'removed' && cv === 0) {
26405 var cs = ft.querySelector('strong:last-of-type');
26406 if (cs && cs.textContent === '0') {
26407 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
26408 }
26409 }
26410 });
26411 }
26412 fmtFromTo();
26413
26414 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
26415 (function() {
26416 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
26417 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
26418 });
26419 var resetBtn = document.getElementById('delta-reset-btn');
26420 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
26421 var csvBtn = document.getElementById('delta-csv-btn');
26422 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
26423 var xlsBtn = document.getElementById('delta-xls-btn');
26424 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
26425 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
26426 function sdFetchUri(path) {
26427 return fetch(path).then(function(r){return r.blob();}).then(function(b){
26428 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
26429 }).catch(function(){return '';});
26430 }
26431 function sdInlineImgs(html, cb) {
26432 var paths=[], seen={};
26433 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
26434 if(!paths.length){cb(html);return;}
26435 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
26436 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
26437 .catch(function(){cb(html);});
26438 }
26439 function buildFullPageHtml(pdfMode) {
26440 if(pdfMode) document.body.classList.add('pdf-mode');
26441 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
26442 renderDeltaPage();
26443 var html = document.documentElement.outerHTML;
26444 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
26445 if(pdfMode) document.body.classList.remove('pdf-mode');
26446 return html;
26447 }
26448 var chartsBtn = document.getElementById('delta-charts-btn');
26449 if (chartsBtn) chartsBtn.addEventListener('click', function() {
26450 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26451 sdInlineImgs(buildFullPageHtml(false), function(html) {
26452 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26453 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26454 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26455 btn.disabled=false;btn.innerHTML=orig;
26456 });
26457 });
26458 var pageHtmlBtn = document.getElementById('page-export-html-btn');
26459 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
26460 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26461 sdInlineImgs(buildFullPageHtml(false), function(html) {
26462 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26463 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26464 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26465 btn.disabled=false;btn.innerHTML=orig;
26466 });
26467 });
26468 // PDF export — clean document-style report, not a web page screenshot
26469 function buildDeltaPdfHtml() {
26470 var sd=_sd, dr=getDeltaExportRows();
26471 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
26472 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
26473 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
26474 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
26475 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26476 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();}
26477 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
26478 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>';}
26479 var lm={};
26480 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;});
26481 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,15);
26482 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
26483 var css='body{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
26484 '.hdr{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}'+
26485 '.brand{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}'+
26486 '.title{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}'+
26487 '.proj{font-size:12px;color:#99aabb;margin-top:3px;}'+
26488 '.hr{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}'+
26489 '.body{padding:18px 24px;}'+
26490 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;}'+
26491 '.sc{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}'+
26492 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
26493 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
26494 '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:12px 16px;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
26495 '.meta>div{flex:1 1 0;}'+
26496 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:4px;font-size:15px;}'+
26497 '.sec{margin-bottom:18px;}'+
26498 '.sh{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}'+
26499 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
26500 'th{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;}'+
26501 'td{border-bottom:1px solid #eee;padding:5px 10px;vertical-align:middle;}'+
26502 'tr:nth-child(even) td{background:#faf8f6;}'+
26503 '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:16px;}';
26504 var fileRows=dr.slice(0,200).map(function(r){
26505 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
26506 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
26507 '<td style="'+ss+'">'+esc(st)+'</td>'+
26508 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
26509 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
26510 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
26511 }).join('');
26512 var more=dr.length>200?'<tr><td colspan="6" style="color:#888;font-style:italic;text-align:center">\u2026 '+fmtN(dr.length-200)+' more files \u2014 export to XLS for full list</td></tr>':'';
26513 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">'+delt(dv)+'</td></tr>';}).join('');
26514 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
26515 '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Scan Delta</div><div class="proj">'+esc(projName)+'</div></div>'+
26516 '<div class="hr">'+esc(_blabel)+'<br>'+esc(_clabel)+'<br>Generated: '+esc(now)+'</div></div>'+
26517 '<div class="body">'+
26518 '<div class="sg">'+
26519 '<div class="sc"><div class="sv">'+delt(sd.cd)+'</div><div class="sl">Code Lines \u0394</div></div>'+
26520 '<div class="sc"><div class="sv">'+delt(sd.fd)+'</div><div class="sl">Files \u0394</div></div>'+
26521 '<div class="sc"><div class="sv">'+delt(sd.cmd)+'</div><div class="sl">Comment Lines \u0394</div></div>'+
26522 '<div class="sc"><div class="sv" style="color:#111">'+fmtN(tfTotal)+'</div><div class="sl">Total Files</div></div>'+
26523 '</div>'+
26524 '<div class="meta">'+
26525 '<div><div class="ml">Baseline Code</div><div class="mv">'+fullN(sd.bc)+'</div></div>'+
26526 '<div><div class="ml">Current Code</div><div class="mv">'+fullN(sd.cc)+'</div></div>'+
26527 '<div><div class="ml">Modified</div><div class="mv">'+fullN(sd.fm)+'</div></div>'+
26528 '<div><div class="ml">Added</div><div class="mv" style="color:#2a6846">+'+fullN(sd.fa)+'</div></div>'+
26529 '<div><div class="ml">Removed</div><div class="mv" style="color:#b23030">-'+fullN(sd.fr)+'</div></div>'+
26530 '<div><div class="ml">Unchanged</div><div class="mv">'+fullN(sd.fu)+'</div></div>'+
26531 '</div>'+
26532 (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files Changed</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
26533 '<div class="sec"><p class="sh">File Delta ('+fmtN(dr.length)+' files)</p>'+
26534 '<table><thead><tr><th>File</th><th>Language</th><th>Status</th>'+
26535 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
26536 '</tr></thead><tbody>'+fileRows+more+'</tbody></table></div>'+
26537 '</div>'+
26538 '<div class="ftr"><span>oxide-sloc v{{ version }}</span><span>Scan Delta Report</span>'+
26539 '<span>'+esc(sd.bid)+' \u2192 '+esc(sd.cid)+'</span></div>'+
26540 '</body></html>';
26541 }
26542 function doDeltaPdf(btn) {
26543 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
26544 var html=buildDeltaPdfHtml();
26545 fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:html,filename:getExportFilename('pdf')})})
26546 .then(function(r){if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();})
26547 .then(function(blob){var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=getExportFilename('pdf');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);})
26548 .catch(function(e){alert('PDF export failed: '+e.message);})
26549 .finally(function(){btn.disabled=false;btn.innerHTML=orig;});
26550 }
26551 var pdfBtn = document.getElementById('delta-pdf-btn');
26552 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
26553 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
26554 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
26555 if (location.protocol === 'file:') {
26556 [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'; } });
26557 [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'; } });
26558 }
26559 var ppSel = document.getElementById('per-page-sel');
26560 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
26561 var pathLink = document.getElementById('project-path-link');
26562 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
26563 })();
26564
26565 // ── Export helpers ────────────────────────────────────────────────────────
26566 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
26567 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26568 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);}
26569 function slocMakeXlsx(fname,sd,dr){
26570 var enc=new TextEncoder();
26571 // CRC-32 table
26572 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;}
26573 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;}
26574 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26575 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26576 // Shared string table
26577 var ss=[],si={};
26578 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
26579 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26580 // Worksheet builder — each WS() call gets its own row counter R
26581 function WS(){
26582 var R=0,buf=[];
26583 function cl(c){return String.fromCharCode(65+c);}
26584 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
26585 '<v>'+S(v)+'</v></c>';}
26586 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
26587 (st?' s="'+st+'"':'')+'>'+
26588 '<v>'+(+v)+'</v></c>';}
26589 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
26590 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26591 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
26592 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
26593 '<sheetFormatPr defaultRowHeight="15"/>'+
26594 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
26595 return{sc:sc,nc:nc,row:row,xml:xml};
26596 }
26597 // Language breakdown
26598 var lm={};
26599 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;});
26600 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
26601 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
26602 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
26603 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
26604 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26605 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26606 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):'';}
26607 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
26608 // Summary sheet
26609 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
26610 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
26611 r1(s1(0,proj,2));
26612 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
26613 r1('');
26614 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
26615 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))));
26616 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))));
26617 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))));
26618 r1('');
26619 r1(s1(0,'FILE CHANGES',8));
26620 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
26621 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
26622 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
26623 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
26624 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
26625 if(langs.length){
26626 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
26627 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
26628 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)));});
26629 }
26630 r1('');r1(s1(0,'SCAN METADATA',8));
26631 r1(s1(1,_blabel)+s1(2,_clabel));
26632 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
26633 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
26634 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"/>');
26635 // File Delta sheet
26636 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
26637 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));
26638 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)));});
26639 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
26640 // Shared strings XML
26641 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26642 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
26643 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
26644 // XLSX file map
26645 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26646 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>',
26647 '_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>',
26648 '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>',
26649 '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>',
26650 '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>',
26651 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
26652 // ZIP packer — STORED (no compression), compatible with all XLSX readers
26653 var zparts=[],zcds=[],zoff=0,znf=0;
26654 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
26655 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
26656 ].forEach(function(name){
26657 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26658 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]);
26659 var entry=new Uint8Array(lha.length+nb.length+sz);
26660 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26661 zparts.push(entry);
26662 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));
26663 var cde=new Uint8Array(cda.length+nb.length);
26664 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26665 zcds.push(cde);zoff+=entry.length;znf++;
26666 });
26667 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26668 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]);
26669 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26670 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26671 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26672 zout.set(new Uint8Array(ea),zpos);
26673 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
26674 var xurl=URL.createObjectURL(xblob);
26675 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
26676 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
26677 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
26678 }
26679 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;');}
26680 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
26681 function getExportFilename(ext){return _exportBase+'.'+ext;}
26682
26683 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 %}};
26684 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;}
26685 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
26686 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
26687 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26688 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26689 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):'';}
26690 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
26691 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)]];}
26692 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
26693 function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){var b=parseInt(tr.getAttribute('data-baseline-code'))||0,c=parseInt(tr.getAttribute('data-current-code'))||0,st=tr.getAttribute('data-status')||'';r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',st,tr.getAttribute('data-baseline-code')||'',tr.getAttribute('data-current-code')||'',tr.getAttribute('data-code-delta')||'',tr.getAttribute('data-comment-delta')||'',tr.getAttribute('data-total-delta')||'',_filePct(b,c,st)]);});return r;}
26694 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
26695 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
26696
26697 // ── Chart HTML report ─────────────────────────────────────────────────────
26698 function slocChartReport(fname, sd, dr) {
26699 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
26700 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26701 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26702 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();}
26703 function px(n){return Math.round(n);}
26704 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
26705 // Language map
26706 var lm={};
26707 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;});
26708 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26709
26710 // Builds onmouse* attrs for interactive tooltip on each SVG element
26711 function barTT(label,val){
26712 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
26713 }
26714
26715 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
26716 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
26717 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26718 var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14;
26719 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26720 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26721 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"/>';}
26722 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26723 c1mets.forEach(function(m,i){
26724 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26725 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26726 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
26727 c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
26728 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
26729 c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
26730 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
26731 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
26732 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
26733 });
26734 c1+='</svg>';
26735
26736 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
26737 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
26738 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26739 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
26740 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
26741 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26742 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26743 mets.forEach(function(m,i){
26744 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
26745 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
26746 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
26747 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
26748 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
26749 if(bw>=52){
26750 c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
26751 }else{
26752 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
26753 c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
26754 }
26755 });
26756 c2+='</svg>';
26757
26758 // ── Chart 3: Language Code Delta ─────────────────────────────────────
26759 var c3='';
26760 if(langs.length){
26761 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26762 var C3W=550,c3LW=124,c3FW=52;
26763 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
26764 var L3rH=30,C3H=langs.length*L3rH+20;
26765 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26766 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26767 langs.forEach(function(l,i){
26768 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
26769 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
26770 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26771 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26772 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 • '+e.f+' file'+(e.f!==1?'s':''))+'/>';
26773 if(bw>=48){
26774 c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
26775 }else{
26776 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
26777 c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
26778 }
26779 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
26780 });
26781 c3+='</svg>';
26782 }
26783
26784 // ── Chart 4: File Change Donut — centered pie with legend below
26785 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;});
26786 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26787 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26788 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">';
26789 var ang=-Math.PI/2;
26790 segs.forEach(function(s){
26791 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26792 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
26793 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
26794 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
26795 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
26796 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 • '+px(s.v/tot*100)+'%')+'/>';
26797 ang+=sw;
26798 });
26799 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
26800 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26801 segs.forEach(function(s,i){
26802 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26803 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
26804 c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
26805 });
26806 c4+='</svg>';
26807
26808 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
26809 var ttJs='var tt=document.getElementById("ox-tt");'+
26810 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
26811 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
26812 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
26813 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
26814 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
26815 'function oxHT(){tt.style.display="none";}';
26816
26817 // body max-width keeps charts from inflating beyond design dimensions on
26818 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
26819 // each chart's height blows up proportionally, breaking the one-page layout.
26820 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;}'+
26821 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
26822 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
26823 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
26824 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
26825 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
26826 'svg{display:block;}'+
26827 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
26828 '#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;}'+
26829 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
26830 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
26831 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26832 '<div id="ox-tt"><\/div>'+
26833 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26834 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
26835 '<div class="two-col">'+
26836 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26837 '<div class="leg">'+
26838 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26839 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26840 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
26841 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
26842 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
26843 '<\/div>'+
26844 '<div class="two-col">'+
26845 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
26846 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
26847 '<\/div>'+
26848 '<script>'+ttJs+'<\/script>'+
26849 '<\/body><\/html>';
26850 slocDownload(html, fname, 'text/html;charset=utf-8;');
26851 }
26852 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
26853 window.buildDeltaChartsHtml = function() {
26854 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26855 var sd=_sd;
26856 var projEl=document.querySelector('[data-folder]');
26857 var proj=projEl?projEl.getAttribute('data-folder'):'';
26858 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
26859 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
26860 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
26861 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
26862 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";}';
26863 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);}';
26864 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26865 '<div id="ox-tt"><\/div>'+
26866 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26867 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' → '+esc(sd.cts||'')+'<\/p>'+
26868 '<div class="two-col">'+
26869 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26870 '<div class="leg"><span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26871 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26872 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
26873 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
26874 '<\/div>'+
26875 '<div class="two-col">'+
26876 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
26877 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
26878 '<\/div>'+
26879 '<script>'+ttJs+'<\/script>'+
26880 '<\/body><\/html>';
26881 };
26882 // ── Inline delta charts ────────────────────────────────────────────────────
26883 var _icTT=document.getElementById('ic-tt');
26884 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
26885 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';};
26886 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
26887 window.addEventListener('blur',function(){window.icHT();});
26888 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
26889 (function(){
26890 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
26891 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26892 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();}
26893 function px(n){return Math.round(n);}
26894 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26895 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
26896 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);});}
26897 var dr=getDeltaExportRows(),sd=_sd,lm={};
26898 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;});
26899 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26900 // Chart 1: Baseline vs Current grouped bars
26901 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
26902 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26903 var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26904 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26905 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"/>';}
26906 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26907 c1mets.forEach(function(m,i){
26908 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26909 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26910 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
26911 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"/>';
26912 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
26913 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"/>';
26914 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
26915 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
26916 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
26917 });
26918 c1+='</svg>';
26919 // Chart 2: Delta by Metric
26920 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
26921 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26922 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;
26923 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26924 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26925 mets.forEach(function(m,i){
26926 var y=16+i*rH,bw=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);
26927 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
26928 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
26929 if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
26930 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,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
26931 });
26932 c2+='</svg>';
26933 // Chart 3: Language Code Delta
26934 var c3='';
26935 if(langs.length){
26936 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26937 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;
26938 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26939 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26940 langs.forEach(function(l,i){
26941 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26942 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26943 c3+='<rect'+btt(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
26944 if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
26945 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,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
26946 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
26947 });
26948 c3+='</svg>';
26949 }
26950 // Chart 4: File Change Donut — centered pie with legend below
26951 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;});
26952 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26953 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26954 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">',ang=-Math.PI/2;
26955 if(segs.length===1){
26956 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
26957 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
26958 } else {
26959 segs.forEach(function(s){
26960 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26961 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);
26962 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);
26963 c4+='<path'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*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="white" stroke-width="2.5"/>';
26964 ang+=sw;
26965 });
26966 }
26967 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt(tot)+'</text>';
26968 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26969 segs.forEach(function(s,i){
26970 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26971 c4+='<rect'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
26972 c4+='<text'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt(s.v)+'</text>';
26973 });
26974 c4+='</svg>';
26975 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
26976 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
26977 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);}
26978 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
26979 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
26980
26981 // Compare Timeline chart (Baseline vs Current, 2 points)
26982 (function() {
26983 var activeCmpMetric='code';
26984 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
26985 function renderCmpTL(metric) {
26986 var svg=document.getElementById('cmp-tl-svg');if(!svg)return;
26987 var W=svg.getBoundingClientRect().width||800,H=280;
26988 svg.setAttribute('height',H);
26989 var pad={l:62,r:20,t:32,b:72};
26990 var dark=document.body.classList.contains('dark-theme');
26991 var cmpPts=[
26992 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
26993 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
26994 ];
26995 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
26996 var valid=pts.filter(function(v){return v!=null;});
26997 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;}
26998 var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
26999 if(minV===maxV){minV=Math.max(0,minV-1);maxV=maxV+1;}
27000 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
27001 var cx0=pad.l,cx1=pad.l+plotW;
27002 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27003 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27004 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
27005 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
27006 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
27007 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();}
27008 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27009 var parts=[];
27010 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
27011 for(var gi=0;gi<5;gi++){
27012 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
27013 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
27014 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
27015 }
27016 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+'"/>');
27017 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"/>');
27018 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
27019 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
27020 dotPts.forEach(function(pt){
27021 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+'">'+fmtN(pt.v)+'</text>');
27022 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"/>');
27023 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>');
27024 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>');
27025 });
27026 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
27027 svg.setAttribute('viewBox','0 0 '+W+' '+H);
27028 svg.innerHTML=parts.join('');
27029 // Hover: crosshair + tooltip (matches multi-scan timeline)
27030 var cmpTT=document.getElementById('ic-tt');
27031 svg.onmousemove=function(e){
27032 var rect=svg.getBoundingClientRect();
27033 var scaleX=W/rect.width;
27034 var mouseX=(e.clientX-rect.left)*scaleX;
27035 var nearest=-1,minDist=Infinity;
27036 var cxArr=[cx0,cx1];
27037 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;}}
27038 if(nearest<0)return;
27039 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
27040 var xhair=svg.querySelector('.cmp-xhair');
27041 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
27042 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"/>';
27043 if(!cmpTT)return;
27044 var clbl=cmpPts[nearest].label;
27045 var scanLbl=nearest===0?'Baseline':'Current';
27046 cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+fmtN(pts[nearest])+'</strong>';
27047 var bx=rect.left+(nc/W*rect.width)+18;
27048 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
27049 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
27050 };
27051 svg.onmouseleave=function(){
27052 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
27053 if(cmpTT)cmpTT.style.display='none';
27054 };
27055 }
27056 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
27057 btn.addEventListener('click',function(){
27058 activeCmpMetric=this.dataset.cmpMetric;
27059 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
27060 this.classList.add('active');
27061 renderCmpTL(activeCmpMetric);
27062 });
27063 });
27064 var ttgl=document.getElementById('theme-toggle');
27065 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);},0);});
27066 if(typeof ResizeObserver!=='undefined'){
27067 var cmpSvg=document.getElementById('cmp-tl-svg');
27068 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
27069 }
27070 renderCmpTL(activeCmpMetric);
27071 })();
27072
27073 // HTML legend hover -> highlight matching SVG bars within the SAME card only
27074 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
27075 var metric=leg.getAttribute('data-highlight');
27076 var parentCard=leg.closest('.ic-card');
27077 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
27078 if(!chartEl)return;
27079 leg.addEventListener('mouseenter',function(){
27080 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
27081 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';}
27082 else{x.style.opacity='0.28';}
27083 });
27084 });
27085 leg.addEventListener('mouseleave',function(){
27086 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
27087 });
27088 });
27089 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
27090 })();
27091 </script>
27092 <script nonce="{{ csp_nonce }}">
27093 (function(){
27094 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'}];
27095 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);});}
27096 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27097 function init(){
27098 var btn=document.getElementById('settings-btn');if(!btn)return;
27099 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27100 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>';
27101 document.body.appendChild(m);
27102 var g=document.getElementById('scheme-grid');
27103 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);});
27104 var cl=document.getElementById('settings-close');
27105 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);
27106 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');});
27107 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27108 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27109 }
27110 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27111 }());
27112 </script>
27113 <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]';
27114 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;}
27115 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>
27116</body>
27117</html>
27118"##,
27119 ext = "html"
27120)]
27121#[allow(clippy::struct_excessive_bools)]
27123struct CompareTemplate {
27124 version: &'static str,
27125 project_label: String,
27126 baseline_git_commit: String,
27127 current_git_commit: String,
27128 baseline_run_id: String,
27129 current_run_id: String,
27130 baseline_run_id_short: String,
27131 current_run_id_short: String,
27132 baseline_timestamp: String,
27133 baseline_timestamp_utc_ms: i64,
27134 current_timestamp: String,
27135 current_timestamp_utc_ms: i64,
27136 project_path: String,
27137 baseline_code: u64,
27138 current_code: u64,
27139 code_lines_delta_str: String,
27140 code_lines_delta_class: String,
27141 baseline_files: u64,
27142 current_files: u64,
27143 files_analyzed_delta_str: String,
27144 files_analyzed_delta_class: String,
27145 baseline_comments: u64,
27146 current_comments: u64,
27147 comment_lines_delta_str: String,
27148 comment_lines_delta_class: String,
27149 baseline_code_fmt: String,
27150 current_code_fmt: String,
27151 baseline_files_fmt: String,
27152 current_files_fmt: String,
27153 baseline_comments_fmt: String,
27154 current_comments_fmt: String,
27155 code_lines_pct_str: String,
27156 files_analyzed_pct_str: String,
27157 comment_lines_pct_str: String,
27158 code_lines_added: i64,
27159 code_lines_removed: i64,
27160 new_scope: bool,
27162 churn_rate_str: String,
27163 churn_rate_class: String,
27164 scope_flag: bool,
27165 files_added: usize,
27166 files_removed: usize,
27167 files_modified: usize,
27168 files_unchanged: usize,
27169 file_rows: Vec<CompareFileDeltaRow>,
27170 baseline_git_author: Option<String>,
27171 current_git_author: Option<String>,
27172 baseline_git_branch: String,
27173 current_git_branch: String,
27174 baseline_git_tags: Option<String>,
27175 current_git_tags: Option<String>,
27176 baseline_git_commit_date: Option<String>,
27177 current_git_commit_date: Option<String>,
27178 project_name: String,
27179 submodule_options: Vec<String>,
27181 has_any_submodule_data: bool,
27183 active_submodule: Option<String>,
27185 super_scope_active: bool,
27187 csp_nonce: String,
27188 coverage_delta_card: String,
27190 baseline_test_count: u64,
27191 current_test_count: u64,
27192 baseline_coverage_pct: Option<f64>,
27193 current_coverage_pct: Option<f64>,
27194}
27195
27196#[derive(Template)]
27199#[template(
27200 source = r##"
27201<!doctype html>
27202<html lang="en">
27203<head>
27204 <meta charset="utf-8">
27205 <meta name="viewport" content="width=device-width, initial-scale=1">
27206 <title>OxideSLOC | Sign In</title>
27207 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27208 <style nonce="{{ csp_nonce }}">
27209 :root {
27210 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
27211 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
27212 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
27213 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
27214 }
27215 *{box-sizing:border-box;}
27216 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);}
27217 .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);}
27218 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
27219 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
27220 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
27221 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27222 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27223 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27224 .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;}
27225 @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));}}
27226 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
27227 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
27228 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27229 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
27230 .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;}
27231 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
27232 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;}
27233 input[type=password]:focus{border-color:var(--oxide);}
27234 .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;}
27235 .btn:hover{opacity:.88;}
27236 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
27237 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
27238 </style>
27239</head>
27240<body>
27241 <div class="background-watermarks" aria-hidden="true">
27242 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27243 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27244 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27245 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27246 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27247 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27248 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27249 </div>
27250 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27251<nav class="top-nav">
27252 <a class="brand" href="/">
27253 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
27254 <span class="brand-title">OxideSLOC</span>
27255 </a>
27256</nav>
27257<main class="page">
27258 <div class="card">
27259 <h1>Sign In</h1>
27260 <p class="subtitle">Enter the API key printed when the server started.</p>
27261 {% if has_error %}
27262 <div class="error">Incorrect API key — please try again.</div>
27263 {% endif %}
27264 <form method="POST" action="/auth/login">
27265 <input type="hidden" name="next" value="{{ next_url|e }}">
27266 <label for="key">API Key</label>
27267 <input id="key" type="password" name="key" autocomplete="current-password"
27268 placeholder="Paste your API key here" autofocus>
27269 <button type="submit" class="btn">Sign In</button>
27270 </form>
27271 <p class="hint">
27272 The API key was printed in the terminal when the server started.<br>
27273 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
27274 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
27275 </p>
27276 </div>
27277</main>
27278<script nonce="{{ csp_nonce }}">
27279(function() {
27280 (function randomizeWatermarks() {
27281 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27282 if (!wms.length) return;
27283 var placed = [];
27284 function tooClose(top, left) {
27285 for (var i = 0; i < placed.length; i++) {
27286 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
27287 if (dt < 16 && dl < 12) return true;
27288 }
27289 return false;
27290 }
27291 function pick(leftBand) {
27292 for (var attempt = 0; attempt < 50; attempt++) {
27293 var top = Math.random() * 88 + 2;
27294 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27295 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
27296 }
27297 var top = Math.random() * 88 + 2;
27298 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27299 placed.push([top, left]); return [top, left];
27300 }
27301 var half = Math.floor(wms.length / 2);
27302 wms.forEach(function (img, i) {
27303 var pos = pick(i < half);
27304 var size = Math.floor(Math.random() * 100 + 120);
27305 var rot = (Math.random() * 360).toFixed(1);
27306 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
27307 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;
27308 });
27309 })();
27310 (function spawnCodeParticles() {
27311 var container = document.getElementById('code-particles');
27312 if (!container) return;
27313 var snippets = [
27314 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
27315 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
27316 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
27317 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
27318 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
27319 ];
27320 var count = 38;
27321 for (var i = 0; i < count; i++) {
27322 (function(idx) {
27323 var el = document.createElement('span');
27324 el.className = 'code-particle';
27325 el.textContent = snippets[idx % snippets.length];
27326 var left = Math.random() * 94 + 2;
27327 var top = Math.random() * 88 + 6;
27328 var dur = (Math.random() * 10 + 9).toFixed(1);
27329 var delay = (Math.random() * 18).toFixed(1);
27330 var rot = (Math.random() * 26 - 13).toFixed(1);
27331 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27332 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
27333 container.appendChild(el);
27334 })(i);
27335 }
27336 })();
27337})();
27338</script>
27339</body>
27340</html>
27341"##,
27342 ext = "html"
27343)]
27344pub(crate) struct LoginTemplate {
27345 pub(crate) csp_nonce: String,
27346 pub(crate) has_error: bool,
27347 pub(crate) next_url: String,
27348 pub(crate) lockout_threshold: u32,
27349}
27350
27351#[derive(Template)]
27354#[template(
27355 source = r##"
27356<!doctype html>
27357<html lang="en">
27358<head>
27359 <meta charset="utf-8">
27360 <meta name="viewport" content="width=device-width, initial-scale=1">
27361 <title>OxideSLOC — REST API Reference</title>
27362 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27363 <style nonce="{{ csp_nonce }}">
27364 :root {
27365 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27366 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27367 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27368 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27369 --success:#16a34a;
27370 }
27371 body.dark-theme {
27372 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
27373 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
27374 }
27375 *{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;}
27376 .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);}
27377 .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;}
27378 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
27379 .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));}
27380 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
27381 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
27382 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
27383 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
27384 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27385 @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; } }
27386 .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;}
27387 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
27388 .nav-pill.active{background:rgba(255,255,255,0.22);}
27389 .nav-dropdown{position:relative;display:inline-flex;}
27390 .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;}
27391 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
27392 .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;}
27393 .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;}
27394 .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);}
27395 .nav-dropdown-menu a:last-child{border-bottom:none;}
27396 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
27397 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
27398 .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;}
27399 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27400 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27401 .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;}
27402 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27403 .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);}
27404 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
27405 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
27406 .settings-modal-body{padding:14px 16px 16px;}
27407 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27408 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27409 .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;}
27410 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27411 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27412 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27413 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27414 .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;}
27415 .tz-select:focus{border-color:var(--oxide);}
27416 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
27417 .page-header{margin-bottom:28px;}
27418 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
27419 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
27420 .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;}
27421 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
27422 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
27423 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
27424 .callout strong{font-weight:800;}
27425 .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;}
27426 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
27427 .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;}
27428 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
27429 .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;}
27430 body.dark-theme .base-url-value{color:var(--accent);}
27431 .section{margin-bottom:36px;}
27432 .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);}
27433 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
27434 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
27435 .ep-header:hover{background:var(--surface-2);}
27436 .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;}
27437 .method.get{background:#dcfce7;color:#166534;}
27438 .method.post{background:#dbeafe;color:#1e40af;}
27439 .method.delete{background:#fee2e2;color:#991b1b;}
27440 body.dark-theme .method.get{background:#14532d;color:#86efac;}
27441 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
27442 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
27443 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
27444 .ep-path .param{color:var(--oxide-2);}
27445 body.dark-theme .ep-path .param{color:var(--oxide);}
27446 .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;}
27447 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
27448 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
27449 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
27450 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
27451 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
27452 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
27453 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
27454 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
27455 .ep-card.open .chevron{transform:rotate(180deg);}
27456 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
27457 .ep-card.open .ep-body{display:block;}
27458 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
27459 .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;}
27460 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
27461 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
27462 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27463 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
27464 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);}
27465 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
27466 table.params tr:last-child td{border-bottom:none;}
27467 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
27468 .pt-type{color:var(--muted-2);font-size:12px;}
27469 .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;}
27470 .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;}
27471 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
27472 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
27473 details.schema{margin-bottom:14px;}
27474 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;}
27475 details.schema summary:hover{color:var(--text);}
27476 .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;}
27477 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27478 .curl-wrap{position:relative;}
27479 .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;}
27480 .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;}
27481 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
27482 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
27483 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
27484 .webhook-note a{color:var(--accent-2);text-decoration:none;}
27485 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27486 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27487 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27488 .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;}
27489 @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));}}
27490 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27491 .site-footer a{color:var(--muted);}
27492 </style>
27493</head>
27494<body>
27495 <div class="background-watermarks" aria-hidden="true">
27496 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27497 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27498 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27499 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27500 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27501 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27502 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27503 </div>
27504 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27505 <div class="top-nav">
27506 <div class="top-nav-inner">
27507 <a class="brand" href="/">
27508 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27509 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
27510 </a>
27511 <div class="nav-right">
27512 <a class="nav-pill" href="/">Home</a>
27513 <div class="nav-dropdown">
27514 <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>
27515 <div class="nav-dropdown-menu">
27516 <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>
27517 </div>
27518 </div>
27519 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27520 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27521 <div class="nav-dropdown">
27522 <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>
27523 <div class="nav-dropdown-menu">
27524 <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>
27525 </div>
27526 </div>
27527 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27528 <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>
27529 </button>
27530 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27531 <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>
27532 <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>
27533 </button>
27534 </div>
27535 </div>
27536 </div>
27537
27538 <div class="page">
27539 <div class="page-header">
27540 <h1 class="page-title">REST API Reference</h1>
27541 <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>
27542 </div>
27543
27544 {% if has_api_key %}
27545 <div class="callout key-set">
27546 <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>
27547 <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>
27548 </div>
27549 {% else %}
27550 <div class="callout no-key">
27551 <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>
27552 <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>
27553 </div>
27554 {% endif %}
27555
27556 <div class="base-url-bar">
27557 <span class="base-url-label">Base URL</span>
27558 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
27559 </div>
27560
27561 <!-- Health -->
27562 <div class="section">
27563 <h2 class="section-title">Health & Status</h2>
27564 <div class="ep-card">
27565 <div class="ep-header">
27566 <span class="method get">GET</span>
27567 <span class="ep-path">/healthz</span>
27568 <span class="auth-badge public">Public</span>
27569 <span class="ep-desc">Server liveness check</span>
27570 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27571 </div>
27572 <div class="ep-body">
27573 <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>
27574 <p class="params-heading">Response</p>
27575 <div class="schema-block">200 OK
27576Content-Type: text/plain
27577
27578ok</div>
27579 <p class="curl-heading">Example</p>
27580 <div class="curl-wrap">
27581 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
27582 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
27583 </div>
27584 </div>
27585 </div>
27586 </div>
27587
27588 <!-- Badges -->
27589 <div class="section">
27590 <h2 class="section-title">Badges</h2>
27591 <div class="ep-card">
27592 <div class="ep-header">
27593 <span class="method get">GET</span>
27594 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
27595 <span class="auth-badge public">Public</span>
27596 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
27597 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27598 </div>
27599 <div class="ep-body">
27600 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
27601 <p class="params-heading">Path Parameters</p>
27602 <table class="params">
27603 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27604 <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>
27605 </table>
27606 <p class="curl-heading">Example</p>
27607 <div class="curl-wrap">
27608 <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>
27609 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
27610 </div>
27611 </div>
27612 </div>
27613 </div>
27614
27615 <!-- Metrics -->
27616 <div class="section">
27617 <h2 class="section-title">Metrics</h2>
27618
27619 <div class="ep-card">
27620 <div class="ep-header">
27621 <span class="method get">GET</span>
27622 <span class="ep-path">/api/metrics/latest</span>
27623 <span class="auth-badge protected">Protected</span>
27624 <span class="ep-desc">Latest scan metrics (JSON)</span>
27625 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27626 </div>
27627 <div class="ep-body">
27628 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
27629 <details class="schema"><summary>Response schema</summary>
27630<div class="schema-block">{
27631 "run_id": string, // UUID
27632 "timestamp": string, // ISO-8601 UTC
27633 "project": string, // scanned root path
27634 "summary": {
27635 "files_analyzed": number,
27636 "files_skipped": number,
27637 "code_lines": number,
27638 "comment_lines": number,
27639 "blank_lines": number,
27640 "total_physical_lines": number,
27641 "functions": number,
27642 "classes": number,
27643 "variables": number,
27644 "imports": number
27645 },
27646 "languages": [
27647 { "name": string, "files": number, "code_lines": number,
27648 "comment_lines": number, "blank_lines": number,
27649 "functions": number, "classes": number,
27650 "variables": number, "imports": number }
27651 ]
27652}</div></details>
27653 <p class="curl-heading">Example</p>
27654 <div class="curl-wrap">
27655 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27656 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
27657 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
27658 </div>
27659 </div>
27660 </div>
27661
27662 <div class="ep-card">
27663 <div class="ep-header">
27664 <span class="method get">GET</span>
27665 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
27666 <span class="auth-badge protected">Protected</span>
27667 <span class="ep-desc">Metrics for a specific run</span>
27668 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27669 </div>
27670 <div class="ep-body">
27671 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
27672 <p class="params-heading">Path Parameters</p>
27673 <table class="params">
27674 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27675 <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>
27676 </table>
27677 <p class="curl-heading">Example</p>
27678 <div class="curl-wrap">
27679 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27680 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
27681 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
27682 </div>
27683 </div>
27684 </div>
27685
27686 <div class="ep-card">
27687 <div class="ep-header">
27688 <span class="method get">GET</span>
27689 <span class="ep-path">/api/metrics/history</span>
27690 <span class="auth-badge protected">Protected</span>
27691 <span class="ep-desc">Paginated scan history</span>
27692 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27693 </div>
27694 <div class="ep-body">
27695 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
27696 <p class="params-heading">Query Parameters</p>
27697 <table class="params">
27698 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27699 <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>
27700 <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>
27701 </table>
27702 <details class="schema"><summary>Response schema</summary>
27703<div class="schema-block">[{
27704 "run_id": string,
27705 "timestamp": string, // ISO-8601 UTC
27706 "commit": string | null,
27707 "branch": string | null,
27708 "tags": string[],
27709 "code_lines": number,
27710 "comment_lines": number,
27711 "blank_lines": number,
27712 "physical_lines": number,
27713 "files_analyzed": number,
27714 "project_label": string,
27715 "html_url": string | null
27716}]</div></details>
27717 <p class="curl-heading">Example</p>
27718 <div class="curl-wrap">
27719 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27720 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
27721 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
27722 </div>
27723 </div>
27724 </div>
27725
27726 <div class="ep-card">
27727 <div class="ep-header">
27728 <span class="method get">GET</span>
27729 <span class="ep-path">/api/project-history</span>
27730 <span class="auth-badge protected">Protected</span>
27731 <span class="ep-desc">Project-level scan summary</span>
27732 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27733 </div>
27734 <div class="ep-body">
27735 <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>
27736 <p class="params-heading">Query Parameters</p>
27737 <table class="params">
27738 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27739 <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>
27740 </table>
27741 <details class="schema"><summary>Response schema</summary>
27742<div class="schema-block">{
27743 "scan_count": number,
27744 "last_scan_id": string | null,
27745 "last_scan_timestamp": string | null, // ISO-8601
27746 "last_scan_code_lines": number | null,
27747 "last_git_branch": string | null,
27748 "last_git_commit": string | null
27749}</div></details>
27750 <p class="curl-heading">Example</p>
27751 <div class="curl-wrap">
27752 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27753 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
27754 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
27755 </div>
27756 </div>
27757 </div>
27758
27759 <div class="ep-card">
27760 <div class="ep-header">
27761 <span class="method get">GET</span>
27762 <span class="ep-path">/api/metrics/submodules</span>
27763 <span class="auth-badge protected">Protected</span>
27764 <span class="ep-desc">List known git submodules across scans</span>
27765 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27766 </div>
27767 <div class="ep-body">
27768 <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>
27769 <p class="params-heading">Query Parameters</p>
27770 <table class="params">
27771 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27772 <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>
27773 </table>
27774 <details class="schema"><summary>Response schema</summary>
27775<div class="schema-block">[{
27776 "name": string, // submodule name
27777 "relative_path": string // path relative to the project root
27778}]</div></details>
27779 <p class="curl-heading">Example</p>
27780 <div class="curl-wrap">
27781 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27782 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
27783 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
27784 </div>
27785 </div>
27786 </div>
27787 </div>
27788
27789 <!-- Async Run Status -->
27790 <div class="section">
27791 <h2 class="section-title">Async Run Status</h2>
27792
27793 <div class="ep-card">
27794 <div class="ep-header">
27795 <span class="method get">GET</span>
27796 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
27797 <span class="auth-badge protected">Protected</span>
27798 <span class="ep-desc">Poll scan completion</span>
27799 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27800 </div>
27801 <div class="ep-body">
27802 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
27803 <details class="schema"><summary>Response schema</summary>
27804<div class="schema-block">// Running
27805{ "state": "running", "elapsed_secs": number }
27806
27807// Complete
27808{ "state": "complete", "run_id": string }
27809
27810// Failed
27811{ "state": "failed", "message": string }</div></details>
27812 <p class="curl-heading">Example</p>
27813 <div class="curl-wrap">
27814 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27815 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
27816 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
27817 </div>
27818 </div>
27819 </div>
27820
27821 <div class="ep-card">
27822 <div class="ep-header">
27823 <span class="method get">GET</span>
27824 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
27825 <span class="auth-badge protected">Protected</span>
27826 <span class="ep-desc">Poll PDF generation readiness</span>
27827 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27828 </div>
27829 <div class="ep-body">
27830 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
27831 <details class="schema"><summary>Response schema</summary>
27832<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
27833 <p class="curl-heading">Example</p>
27834 <div class="curl-wrap">
27835 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27836 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
27837 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
27838 </div>
27839 </div>
27840 </div>
27841
27842 <div class="ep-card">
27843 <div class="ep-header">
27844 <span class="method post">POST</span>
27845 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
27846 <span class="auth-badge protected">Protected</span>
27847 <span class="ep-desc">Cancel a running scan</span>
27848 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27849 </div>
27850 <div class="ep-body">
27851 <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>
27852 <p class="curl-heading">Example</p>
27853 <div class="curl-wrap">
27854 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
27855 -H "Authorization: Bearer $SLOC_API_KEY" \
27856 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
27857 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
27858 </div>
27859 </div>
27860 </div>
27861 </div>
27862
27863 <!-- Run Management -->
27864 <div class="section">
27865 <h2 class="section-title">Run Management</h2>
27866
27867 <div class="ep-card">
27868 <div class="ep-header">
27869 <span class="method get">GET</span>
27870 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
27871 <span class="auth-badge protected">Protected</span>
27872 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
27873 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27874 </div>
27875 <div class="ep-body">
27876 <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>
27877 <p class="params-heading">Path Parameters</p>
27878 <table class="params">
27879 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27880 <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>
27881 </table>
27882 <details class="schema"><summary>Response</summary>
27883<div class="schema-block">200 OK — Content-Type: application/zip
27884Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
27885
27886404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
27887 <p class="curl-heading">Example</p>
27888 <div class="curl-wrap">
27889 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27890 -o run.zip \
27891 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
27892 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
27893 </div>
27894 </div>
27895 </div>
27896
27897 <div class="ep-card">
27898 <div class="ep-header">
27899 <span class="method delete">DELETE</span>
27900 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
27901 <span class="auth-badge protected">Protected</span>
27902 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
27903 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27904 </div>
27905 <div class="ep-body">
27906 <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>
27907 <p class="params-heading">Path Parameters</p>
27908 <table class="params">
27909 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27910 <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>
27911 </table>
27912 <details class="schema"><summary>Response</summary>
27913<div class="schema-block">204 No Content — run successfully deleted
27914
27915500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
27916 <p class="curl-heading">Example</p>
27917 <div class="curl-wrap">
27918 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
27919 -H "Authorization: Bearer $SLOC_API_KEY" \
27920 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
27921 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
27922 </div>
27923 </div>
27924 </div>
27925
27926 <div class="ep-card">
27927 <div class="ep-header">
27928 <span class="method post">POST</span>
27929 <span class="ep-path">/api/runs/cleanup</span>
27930 <span class="auth-badge protected">Protected</span>
27931 <span class="ep-desc">Bulk delete runs older than N days</span>
27932 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27933 </div>
27934 <div class="ep-body">
27935 <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>
27936 <p class="params-heading">Request Body (application/json)</p>
27937 <table class="params">
27938 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
27939 <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>
27940 </table>
27941 <details class="schema"><summary>Response schema</summary>
27942<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
27943 <p class="curl-heading">Example — delete runs older than 60 days</p>
27944 <div class="curl-wrap">
27945 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
27946 -H "Authorization: Bearer $SLOC_API_KEY" \
27947 -H "Content-Type: application/json" \
27948 -d '{"older_than_days":60}' \
27949 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
27950 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
27951 </div>
27952 </div>
27953 </div>
27954 </div>
27955
27956 <!-- Retention Policy -->
27957 <div class="section">
27958 <h2 class="section-title">Retention Policy</h2>
27959
27960 <div class="ep-card">
27961 <div class="ep-header">
27962 <span class="method get">GET</span>
27963 <span class="ep-path">/api/cleanup-policy</span>
27964 <span class="auth-badge protected">Protected</span>
27965 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
27966 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27967 </div>
27968 <div class="ep-body">
27969 <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>
27970 <details class="schema"><summary>Response schema</summary>
27971<div class="schema-block">{
27972 "policy": {
27973 "enabled": boolean,
27974 "max_age_days": number | null, // delete runs older than N days
27975 "max_run_count": number | null, // keep only the N most recent runs
27976 "interval_hours": number // hours between background passes
27977 } | null,
27978 "last_run_at": string | null, // ISO-8601 UTC timestamp
27979 "last_run_deleted": number | null // runs deleted in last pass
27980}</div></details>
27981 <p class="curl-heading">Example</p>
27982 <div class="curl-wrap">
27983 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27984 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
27985 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
27986 </div>
27987 </div>
27988 </div>
27989
27990 <div class="ep-card">
27991 <div class="ep-header">
27992 <span class="method post">POST</span>
27993 <span class="ep-path">/api/cleanup-policy</span>
27994 <span class="auth-badge protected">Protected</span>
27995 <span class="ep-desc">Save or update the retention policy</span>
27996 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27997 </div>
27998 <div class="ep-body">
27999 <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>
28000 <p class="params-heading">Request Body (application/json)</p>
28001 <table class="params">
28002 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28003 <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>
28004 <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>
28005 <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>
28006 <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>
28007 </table>
28008 <details class="schema"><summary>Response</summary>
28009<div class="schema-block">204 No Content — policy saved and task (re)started
28010
28011500 Internal Server Error — { "error": string }</div></details>
28012 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
28013 <div class="curl-wrap">
28014 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
28015 -H "Authorization: Bearer $SLOC_API_KEY" \
28016 -H "Content-Type: application/json" \
28017 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
28018 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28019 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
28020 </div>
28021 </div>
28022 </div>
28023
28024 <div class="ep-card">
28025 <div class="ep-header">
28026 <span class="method post">POST</span>
28027 <span class="ep-path">/api/cleanup-policy/run-now</span>
28028 <span class="auth-badge protected">Protected</span>
28029 <span class="ep-desc">Trigger an immediate cleanup pass</span>
28030 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28031 </div>
28032 <div class="ep-body">
28033 <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>
28034 <details class="schema"><summary>Response schema</summary>
28035<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
28036 <p class="curl-heading">Example</p>
28037 <div class="curl-wrap">
28038 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
28039 -H "Authorization: Bearer $SLOC_API_KEY" \
28040 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
28041 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
28042 </div>
28043 </div>
28044 </div>
28045
28046 <div class="ep-card">
28047 <div class="ep-header">
28048 <span class="method delete">DELETE</span>
28049 <span class="ep-path">/api/cleanup-policy</span>
28050 <span class="auth-badge protected">Protected</span>
28051 <span class="ep-desc">Remove the retention policy and stop the background task</span>
28052 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28053 </div>
28054 <div class="ep-body">
28055 <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>
28056 <details class="schema"><summary>Response</summary>
28057<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
28058 <p class="curl-heading">Example</p>
28059 <div class="curl-wrap">
28060 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
28061 -H "Authorization: Bearer $SLOC_API_KEY" \
28062 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28063 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
28064 </div>
28065 </div>
28066 </div>
28067 </div>
28068
28069 <!-- Scan Profiles -->
28070 <div class="section">
28071 <h2 class="section-title">Scan Profiles</h2>
28072
28073 <div class="ep-card">
28074 <div class="ep-header">
28075 <span class="method get">GET</span>
28076 <span class="ep-path">/api/scan-profiles</span>
28077 <span class="auth-badge protected">Protected</span>
28078 <span class="ep-desc">List saved scan profiles</span>
28079 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28080 </div>
28081 <div class="ep-body">
28082 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
28083 <details class="schema"><summary>Response schema</summary>
28084<div class="schema-block">{
28085 "profiles": [{
28086 "id": string, // UUID
28087 "name": string,
28088 "created_at": string, // ISO-8601
28089 "params": object
28090 }]
28091}</div></details>
28092 <p class="curl-heading">Example</p>
28093 <div class="curl-wrap">
28094 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28095 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28096 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
28097 </div>
28098 </div>
28099 </div>
28100
28101 <div class="ep-card">
28102 <div class="ep-header">
28103 <span class="method post">POST</span>
28104 <span class="ep-path">/api/scan-profiles</span>
28105 <span class="auth-badge protected">Protected</span>
28106 <span class="ep-desc">Save a scan profile</span>
28107 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28108 </div>
28109 <div class="ep-body">
28110 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
28111 <p class="params-heading">Request Body (application/json)</p>
28112 <table class="params">
28113 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28114 <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>
28115 <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>
28116 </table>
28117 <details class="schema"><summary>Response schema</summary>
28118<div class="schema-block">{ "ok": true }</div></details>
28119 <p class="curl-heading">Example</p>
28120 <div class="curl-wrap">
28121 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
28122 -H "Authorization: Bearer $SLOC_API_KEY" \
28123 -H "Content-Type: application/json" \
28124 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
28125 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28126 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
28127 </div>
28128 </div>
28129 </div>
28130
28131 <div class="ep-card">
28132 <div class="ep-header">
28133 <span class="method delete">DELETE</span>
28134 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
28135 <span class="auth-badge protected">Protected</span>
28136 <span class="ep-desc">Delete a scan profile</span>
28137 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28138 </div>
28139 <div class="ep-body">
28140 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
28141 <p class="params-heading">Path Parameters</p>
28142 <table class="params">
28143 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28144 <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>
28145 </table>
28146 <details class="schema"><summary>Response schema</summary>
28147<div class="schema-block">{ "ok": true }</div></details>
28148 <p class="curl-heading">Example</p>
28149 <div class="curl-wrap">
28150 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
28151 -H "Authorization: Bearer $SLOC_API_KEY" \
28152 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
28153 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
28154 </div>
28155 </div>
28156 </div>
28157 </div>
28158
28159 <!-- Scheduled Scans -->
28160 <div class="section">
28161 <h2 class="section-title">Scheduled Scans</h2>
28162
28163 <div class="ep-card">
28164 <div class="ep-header">
28165 <span class="method get">GET</span>
28166 <span class="ep-path">/api/schedules</span>
28167 <span class="auth-badge protected">Protected</span>
28168 <span class="ep-desc">List configured schedules</span>
28169 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28170 </div>
28171 <div class="ep-body">
28172 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
28173 <p class="curl-heading">Example</p>
28174 <div class="curl-wrap">
28175 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28176 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28177 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
28178 </div>
28179 </div>
28180 </div>
28181
28182 <div class="ep-card">
28183 <div class="ep-header">
28184 <span class="method post">POST</span>
28185 <span class="ep-path">/api/schedules</span>
28186 <span class="auth-badge protected">Protected</span>
28187 <span class="ep-desc">Create a schedule</span>
28188 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28189 </div>
28190 <div class="ep-body">
28191 <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>
28192 <p class="curl-heading">Example</p>
28193 <div class="curl-wrap">
28194 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
28195 -H "Authorization: Bearer $SLOC_API_KEY" \
28196 -H "Content-Type: application/json" \
28197 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
28198 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28199 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
28200 </div>
28201 </div>
28202 </div>
28203
28204 <div class="ep-card">
28205 <div class="ep-header">
28206 <span class="method delete">DELETE</span>
28207 <span class="ep-path">/api/schedules</span>
28208 <span class="auth-badge protected">Protected</span>
28209 <span class="ep-desc">Delete a schedule</span>
28210 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28211 </div>
28212 <div class="ep-body">
28213 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
28214 <p class="curl-heading">Example</p>
28215 <div class="curl-wrap">
28216 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
28217 -H "Authorization: Bearer $SLOC_API_KEY" \
28218 -H "Content-Type: application/json" \
28219 -d '{"id":"<schedule_id>"}' \
28220 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28221 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
28222 </div>
28223 </div>
28224 </div>
28225 </div>
28226
28227 <!-- Git Browser -->
28228 <div class="section">
28229 <h2 class="section-title">Git Browser</h2>
28230
28231 <div class="ep-card">
28232 <div class="ep-header">
28233 <span class="method get">GET</span>
28234 <span class="ep-path">/api/git/refs</span>
28235 <span class="auth-badge protected">Protected</span>
28236 <span class="ep-desc">List git refs for a repository</span>
28237 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28238 </div>
28239 <div class="ep-body">
28240 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
28241 <p class="params-heading">Query Parameters</p>
28242 <table class="params">
28243 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28244 <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>
28245 </table>
28246 <p class="curl-heading">Example</p>
28247 <div class="curl-wrap">
28248 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28249 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
28250 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
28251 </div>
28252 </div>
28253 </div>
28254
28255 <div class="ep-card">
28256 <div class="ep-header">
28257 <span class="method get">GET</span>
28258 <span class="ep-path">/api/git/scan-ref</span>
28259 <span class="auth-badge protected">Protected</span>
28260 <span class="ep-desc">SLOC-scan a specific git ref</span>
28261 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28262 </div>
28263 <div class="ep-body">
28264 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
28265 <p class="params-heading">Query Parameters</p>
28266 <table class="params">
28267 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28268 <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>
28269 <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>
28270 </table>
28271 <p class="curl-heading">Example</p>
28272 <div class="curl-wrap">
28273 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28274 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
28275 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
28276 </div>
28277 </div>
28278 </div>
28279
28280 <div class="ep-card">
28281 <div class="ep-header">
28282 <span class="method get">GET</span>
28283 <span class="ep-path">/api/git/compare-refs</span>
28284 <span class="auth-badge protected">Protected</span>
28285 <span class="ep-desc">Compare SLOC across two git refs</span>
28286 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28287 </div>
28288 <div class="ep-body">
28289 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
28290 <p class="params-heading">Query Parameters</p>
28291 <table class="params">
28292 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28293 <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>
28294 <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>
28295 <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>
28296 </table>
28297 <p class="curl-heading">Example</p>
28298 <div class="curl-wrap">
28299 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28300 "<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>
28301 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
28302 </div>
28303 </div>
28304 </div>
28305 </div>
28306
28307 <!-- Webhooks -->
28308 <div class="section">
28309 <h2 class="section-title">Webhooks</h2>
28310 <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>
28311
28312 <div class="ep-card">
28313 <div class="ep-header">
28314 <span class="method post">POST</span>
28315 <span class="ep-path">/webhooks/github</span>
28316 <span class="auth-badge hmac">HMAC</span>
28317 <span class="ep-desc">GitHub push event receiver</span>
28318 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28319 </div>
28320 <div class="ep-body">
28321 <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>
28322 <p class="params-heading">Required Headers</p>
28323 <table class="params">
28324 <tr><th>Header</th><th>Value</th></tr>
28325 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
28326 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
28327 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28328 </table>
28329 </div>
28330 </div>
28331
28332 <div class="ep-card">
28333 <div class="ep-header">
28334 <span class="method post">POST</span>
28335 <span class="ep-path">/webhooks/gitlab</span>
28336 <span class="auth-badge hmac">HMAC</span>
28337 <span class="ep-desc">GitLab push event receiver</span>
28338 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28339 </div>
28340 <div class="ep-body">
28341 <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>
28342 <p class="params-heading">Required Headers</p>
28343 <table class="params">
28344 <tr><th>Header</th><th>Value</th></tr>
28345 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
28346 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
28347 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28348 </table>
28349 </div>
28350 </div>
28351
28352 <div class="ep-card">
28353 <div class="ep-header">
28354 <span class="method post">POST</span>
28355 <span class="ep-path">/webhooks/bitbucket</span>
28356 <span class="auth-badge hmac">HMAC</span>
28357 <span class="ep-desc">Bitbucket push event receiver</span>
28358 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28359 </div>
28360 <div class="ep-body">
28361 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
28362 <p class="params-heading">Required Headers</p>
28363 <table class="params">
28364 <tr><th>Header</th><th>Value</th></tr>
28365 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
28366 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28367 </table>
28368 </div>
28369 </div>
28370 </div>
28371
28372 <!-- Config -->
28373 <div class="section">
28374 <h2 class="section-title">Config Import / Export</h2>
28375
28376 <div class="ep-card">
28377 <div class="ep-header">
28378 <span class="method get">GET</span>
28379 <span class="ep-path">/export-config</span>
28380 <span class="auth-badge protected">Protected</span>
28381 <span class="ep-desc">Export server configuration as JSON</span>
28382 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28383 </div>
28384 <div class="ep-body">
28385 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
28386 <p class="curl-heading">Example</p>
28387 <div class="curl-wrap">
28388 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28389 -o config.json \
28390 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
28391 <button class="curl-copy-btn" data-target="c-export">Copy</button>
28392 </div>
28393 </div>
28394 </div>
28395
28396 <div class="ep-card">
28397 <div class="ep-header">
28398 <span class="method post">POST</span>
28399 <span class="ep-path">/import-config</span>
28400 <span class="auth-badge protected">Protected</span>
28401 <span class="ep-desc">Import server configuration</span>
28402 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28403 </div>
28404 <div class="ep-body">
28405 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
28406 <p class="curl-heading">Example</p>
28407 <div class="curl-wrap">
28408 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
28409 -H "Authorization: Bearer $SLOC_API_KEY" \
28410 -H "Content-Type: application/json" \
28411 -d @config.json \
28412 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
28413 <button class="curl-copy-btn" data-target="c-import">Copy</button>
28414 </div>
28415 </div>
28416 </div>
28417 </div>
28418
28419 <!-- CI Ingest -->
28420 <div class="section">
28421 <h2 class="section-title">CI Ingest</h2>
28422
28423 <div class="ep-card">
28424 <div class="ep-header">
28425 <span class="method post">POST</span>
28426 <span class="ep-path">/api/ingest</span>
28427 <span class="auth-badge protected">Protected</span>
28428 <span class="ep-desc">Push a pre-computed scan result from CI</span>
28429 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28430 </div>
28431 <div class="ep-body">
28432 <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>
28433 <p class="params-heading">Query Parameters</p>
28434 <table class="params">
28435 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28436 <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>
28437 </table>
28438 <p class="params-heading">Request Body (application/json)</p>
28439 <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>
28440 <details class="schema"><summary>Response schema</summary>
28441<div class="schema-block">// 201 Created
28442{
28443 "run_id": string, // UUID of the ingested run
28444 "view_url": string // relative URL to the report page
28445}</div></details>
28446 <p class="curl-heading">Example</p>
28447 <div class="curl-wrap">
28448 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
28449 -H "Authorization: Bearer $SLOC_API_KEY" \
28450 -H "Content-Type: application/json" \
28451 -d @result.json \
28452 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
28453 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
28454 </div>
28455 </div>
28456 </div>
28457 </div>
28458
28459 <!-- Artifact Download -->
28460 <div class="section">
28461 <h2 class="section-title">Artifact Download</h2>
28462
28463 <div class="ep-card">
28464 <div class="ep-header">
28465 <span class="method get">GET</span>
28466 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
28467 <span class="auth-badge protected">Protected</span>
28468 <span class="ep-desc">Download or view a scan artifact</span>
28469 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28470 </div>
28471 <div class="ep-body">
28472 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
28473 <p class="params-heading">Path Parameters</p>
28474 <table class="params">
28475 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28476 <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>
28477 <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>
28478 </table>
28479 <p class="params-heading">Query Parameters</p>
28480 <table class="params">
28481 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28482 <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>
28483 </table>
28484 <p class="curl-heading">Example — download JSON result</p>
28485 <div class="curl-wrap">
28486 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28487 -o result.json \
28488 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
28489 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
28490 </div>
28491 </div>
28492 </div>
28493 </div>
28494
28495 <!-- Embed Widget -->
28496 <div class="section">
28497 <h2 class="section-title">Embed Widget</h2>
28498
28499 <div class="ep-card">
28500 <div class="ep-header">
28501 <span class="method get">GET</span>
28502 <span class="ep-path">/embed/summary</span>
28503 <span class="auth-badge protected">Protected</span>
28504 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
28505 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28506 </div>
28507 <div class="ep-body">
28508 <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>
28509 <p class="params-heading">Query Parameters</p>
28510 <table class="params">
28511 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28512 <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>
28513 <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>
28514 </table>
28515 <p class="curl-heading">Example</p>
28516 <div class="curl-wrap">
28517 <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"
28518 width="460" height="260" style="border:none"></iframe></pre>
28519 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
28520 </div>
28521 </div>
28522 </div>
28523 </div>
28524
28525 <!-- Confluence Integration -->
28526 <div class="section">
28527 <h2 class="section-title">Confluence Integration</h2>
28528
28529 <div class="ep-card">
28530 <div class="ep-header">
28531 <span class="method get">GET</span>
28532 <span class="ep-path">/api/confluence/config</span>
28533 <span class="auth-badge protected">Protected</span>
28534 <span class="ep-desc">Get current Confluence configuration</span>
28535 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28536 </div>
28537 <div class="ep-body">
28538 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
28539 <details class="schema"><summary>Response schema</summary>
28540<div class="schema-block">{
28541 "configured": boolean,
28542 "tier": "cloud" | "server",
28543 "base_url": string,
28544 "username": string,
28545 "api_token_set": boolean,
28546 "space_key": string,
28547 "parent_page_id": string | null,
28548 "schedule_auto_post": { "<schedule_id>": boolean }
28549}</div></details>
28550 <p class="curl-heading">Example</p>
28551 <div class="curl-wrap">
28552 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28553 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28554 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
28555 </div>
28556 </div>
28557 </div>
28558
28559 <div class="ep-card">
28560 <div class="ep-header">
28561 <span class="method post">POST</span>
28562 <span class="ep-path">/api/confluence/config</span>
28563 <span class="auth-badge protected">Protected</span>
28564 <span class="ep-desc">Save Confluence configuration</span>
28565 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28566 </div>
28567 <div class="ep-body">
28568 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
28569 <p class="params-heading">Request Body (application/json)</p>
28570 <table class="params">
28571 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28572 <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>
28573 <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>
28574 <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>
28575 <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>
28576 <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>
28577 <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>
28578 <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>
28579 </table>
28580 <details class="schema"><summary>Response schema</summary>
28581<div class="schema-block">{ "ok": true }</div></details>
28582 <p class="curl-heading">Example</p>
28583 <div class="curl-wrap">
28584 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
28585 -H "Authorization: Bearer $SLOC_API_KEY" \
28586 -H "Content-Type: application/json" \
28587 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
28588 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28589 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
28590 </div>
28591 </div>
28592 </div>
28593
28594 <div class="ep-card">
28595 <div class="ep-header">
28596 <span class="method post">POST</span>
28597 <span class="ep-path">/api/confluence/test</span>
28598 <span class="auth-badge protected">Protected</span>
28599 <span class="ep-desc">Test Confluence connection</span>
28600 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28601 </div>
28602 <div class="ep-body">
28603 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
28604 <details class="schema"><summary>Response schema</summary>
28605<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
28606 <p class="curl-heading">Example</p>
28607 <div class="curl-wrap">
28608 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
28609 -H "Authorization: Bearer $SLOC_API_KEY" \
28610 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
28611 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
28612 </div>
28613 </div>
28614 </div>
28615
28616 <div class="ep-card">
28617 <div class="ep-header">
28618 <span class="method post">POST</span>
28619 <span class="ep-path">/api/confluence/post</span>
28620 <span class="auth-badge protected">Protected</span>
28621 <span class="ep-desc">Publish a scan report to Confluence</span>
28622 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28623 </div>
28624 <div class="ep-body">
28625 <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>
28626 <p class="params-heading">Request Body (application/json)</p>
28627 <table class="params">
28628 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28629 <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>
28630 <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>
28631 <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>
28632 </table>
28633 <details class="schema"><summary>Response schema</summary>
28634<div class="schema-block">// 200 OK
28635{ "ok": true, "page_id": string }
28636
28637// 400 / 502 on error
28638{ "ok": false, "error": string }</div></details>
28639 <p class="curl-heading">Example</p>
28640 <div class="curl-wrap">
28641 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
28642 -H "Authorization: Bearer $SLOC_API_KEY" \
28643 -H "Content-Type: application/json" \
28644 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
28645 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
28646 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
28647 </div>
28648 </div>
28649 </div>
28650
28651 <div class="ep-card">
28652 <div class="ep-header">
28653 <span class="method get">GET</span>
28654 <span class="ep-path">/api/confluence/wiki-markup</span>
28655 <span class="auth-badge protected">Protected</span>
28656 <span class="ep-desc">Get Confluence wiki markup for a run</span>
28657 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28658 </div>
28659 <div class="ep-body">
28660 <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>
28661 <p class="params-heading">Query Parameters</p>
28662 <table class="params">
28663 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28664 <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>
28665 </table>
28666 <p class="curl-heading">Example</p>
28667 <div class="curl-wrap">
28668 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28669 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
28670 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
28671 </div>
28672 </div>
28673 </div>
28674 </div>
28675
28676 <!-- Authentication -->
28677 <div class="section">
28678 <h2 class="section-title">Authentication</h2>
28679 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
28680
28681 <div class="ep-card">
28682 <div class="ep-header">
28683 <span class="method get">GET</span>
28684 <span class="ep-path">/auth/login</span>
28685 <span class="auth-badge public">Public</span>
28686 <span class="ep-desc">Login page</span>
28687 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28688 </div>
28689 <div class="ep-body">
28690 <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>
28691 <p class="params-heading">Query Parameters</p>
28692 <table class="params">
28693 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28694 <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>
28695 <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>
28696 </table>
28697 </div>
28698 </div>
28699
28700 <div class="ep-card">
28701 <div class="ep-header">
28702 <span class="method post">POST</span>
28703 <span class="ep-path">/auth/login</span>
28704 <span class="auth-badge public">Public</span>
28705 <span class="ep-desc">Submit credentials and get a session cookie</span>
28706 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28707 </div>
28708 <div class="ep-body">
28709 <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>
28710 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
28711 <table class="params">
28712 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28713 <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>
28714 <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>
28715 </table>
28716 <p class="curl-heading">Example</p>
28717 <div class="curl-wrap">
28718 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
28719 -d "key=$SLOC_API_KEY&next=/" \
28720 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
28721 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
28722 </div>
28723 </div>
28724 </div>
28725 </div>
28726
28727 <!-- Coverage Suggestion -->
28728 <div class="section">
28729 <h2 class="section-title">Coverage Suggestion</h2>
28730
28731 <div class="ep-card">
28732 <div class="ep-header">
28733 <span class="method get">GET</span>
28734 <span class="ep-path">/api/suggest-coverage</span>
28735 <span class="auth-badge protected">Protected</span>
28736 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
28737 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28738 </div>
28739 <div class="ep-body">
28740 <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML) and returns the first one found, along with a hint for how to generate it if not present.</p>
28741 <p class="params-heading">Query Parameters</p>
28742 <table class="params">
28743 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28744 <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>
28745 </table>
28746 <details class="schema"><summary>Response schema</summary>
28747<div class="schema-block">{
28748 "found": string | null, // absolute path to the coverage file, if detected
28749 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
28750 "hint": string | null // shell command to generate coverage if not found
28751}</div></details>
28752 <p class="curl-heading">Example</p>
28753 <div class="curl-wrap">
28754 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28755 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
28756 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
28757 </div>
28758 </div>
28759 </div>
28760 </div>
28761
28762 </div>
28763
28764 <footer class="site-footer">
28765 local code analysis - metrics, history and reports
28766 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28767 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28768 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28769 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28770 · <a href="/api-docs" rel="noopener">REST API</a>
28771 </footer>
28772
28773 <script nonce="{{ csp_nonce }}">
28774 (function () {
28775 var base = window.location.origin;
28776 document.getElementById('base-url').textContent = base;
28777 document.querySelectorAll('.base-url-slot').forEach(function (el) {
28778 el.textContent = base;
28779 });
28780
28781 document.querySelectorAll('.ep-header').forEach(function (hdr) {
28782 hdr.addEventListener('click', function () {
28783 hdr.closest('.ep-card').classList.toggle('open');
28784 });
28785 });
28786
28787 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
28788 btn.addEventListener('click', function () {
28789 var targetId = btn.dataset.target;
28790 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
28791 if (!pre) return;
28792 navigator.clipboard.writeText(pre.textContent).then(function () {
28793 btn.textContent = 'Copied!';
28794 btn.classList.add('copied');
28795 setTimeout(function () {
28796 btn.textContent = 'Copy';
28797 btn.classList.remove('copied');
28798 }, 2000);
28799 });
28800 });
28801 });
28802
28803 var storageKey = 'oxide-sloc-theme';
28804 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
28805 var themeBtn = document.getElementById('theme-toggle');
28806 if (themeBtn) {
28807 themeBtn.addEventListener('click', function () {
28808 var dark = document.body.classList.toggle('dark-theme');
28809 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
28810 });
28811 }
28812 (function() {
28813 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'}];
28814 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);});}
28815 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28816 var btn=document.getElementById('settings-btn');if(!btn)return;
28817 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28818 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>';
28819 document.body.appendChild(m);
28820 var g=document.getElementById('scheme-grid');
28821 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);});
28822 var cl=document.getElementById('settings-close');
28823 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);
28824 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');});
28825 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28826 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28827 })();
28828 (function randomizeWatermarks() {
28829 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28830 if (!wms.length) return;
28831 var placed = [];
28832 function tooClose(top, left) {
28833 for (var i = 0; i < placed.length; i++) {
28834 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
28835 if (dt < 16 && dl < 12) return true;
28836 }
28837 return false;
28838 }
28839 function pick(leftBand) {
28840 for (var attempt = 0; attempt < 50; attempt++) {
28841 var top = Math.random() * 88 + 2;
28842 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28843 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28844 }
28845 var top = Math.random() * 88 + 2;
28846 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28847 placed.push([top, left]); return [top, left];
28848 }
28849 var half = Math.floor(wms.length / 2);
28850 wms.forEach(function (img, i) {
28851 var pos = pick(i < half);
28852 var size = Math.floor(Math.random() * 100 + 120);
28853 var rot = (Math.random() * 360).toFixed(1);
28854 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
28855 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;
28856 });
28857 })();
28858 (function spawnCodeParticles() {
28859 var container = document.getElementById('code-particles');
28860 if (!container) return;
28861 var snippets = [
28862 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
28863 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
28864 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
28865 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
28866 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
28867 ];
28868 var count = 38;
28869 for (var i = 0; i < count; i++) {
28870 (function(idx) {
28871 var el = document.createElement('span');
28872 el.className = 'code-particle';
28873 el.textContent = snippets[idx % snippets.length];
28874 var left = Math.random() * 94 + 2;
28875 var top = Math.random() * 88 + 6;
28876 var dur = (Math.random() * 10 + 9).toFixed(1);
28877 var delay = (Math.random() * 18).toFixed(1);
28878 var rot = (Math.random() * 26 - 13).toFixed(1);
28879 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28880 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
28881 container.appendChild(el);
28882 })(i);
28883 }
28884 })();
28885 }());
28886 </script>
28887</body>
28888</html>
28889"##,
28890 ext = "html"
28891)]
28892struct ApiDocsTemplate {
28893 has_api_key: bool,
28894 csp_nonce: String,
28895 version: &'static str,
28896}
28897
28898#[cfg(test)]
28899mod form_config_tests {
28900 use super::*;
28901 use sloc_config::{
28902 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
28903 };
28904
28905 fn blank_form() -> AnalyzeForm {
28906 AnalyzeForm {
28907 path: ".".to_string(),
28908 git_repo: None,
28909 git_ref: None,
28910 mixed_line_policy: None,
28911 python_docstrings_as_comments: None,
28912 generated_file_detection: None,
28913 minified_file_detection: None,
28914 vendor_directory_detection: None,
28915 include_lockfiles: None,
28916 binary_file_behavior: None,
28917 output_dir: None,
28918 report_title: None,
28919 report_header_footer: None,
28920 include_globs: None,
28921 exclude_globs: None,
28922 submodule_breakdown: None,
28923 coverage_file: None,
28924 continuation_line_policy: None,
28925 blank_in_block_comment_policy: None,
28926 count_compiler_directives: None,
28927 style_col_threshold: None,
28928 style_analysis_enabled: None,
28929 style_score_threshold: None,
28930 style_lang_scope: None,
28931 cocomo_mode: None,
28932 complexity_alert: None,
28933 exclude_duplicates: None,
28934 }
28935 }
28936
28937 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
28938 let mut cfg = sloc_config::AppConfig::default();
28939 apply_form_to_config(&mut cfg, form);
28940 cfg
28941 }
28942
28943 #[test]
28946 fn python_docstrings_false_when_unchecked() {
28947 let cfg = apply(&blank_form());
28949 assert!(
28950 !cfg.analysis.python_docstrings_as_comments,
28951 "absent python_docstrings_as_comments must map to false"
28952 );
28953 }
28954
28955 #[test]
28956 fn python_docstrings_true_when_checked() {
28957 let mut form = blank_form();
28959 form.python_docstrings_as_comments = Some("on".to_string());
28960 let cfg = apply(&form);
28961 assert!(cfg.analysis.python_docstrings_as_comments);
28962 }
28963
28964 #[test]
28965 fn python_docstrings_true_for_any_non_none_value() {
28966 let mut form = blank_form();
28968 form.python_docstrings_as_comments = Some("true".to_string());
28969 assert!(apply(&form).analysis.python_docstrings_as_comments);
28970 }
28971
28972 #[test]
28975 fn submodule_breakdown_false_when_unchecked() {
28976 let cfg = apply(&blank_form());
28977 assert!(
28978 !cfg.discovery.submodule_breakdown,
28979 "absent submodule_breakdown must map to false"
28980 );
28981 }
28982
28983 #[test]
28984 fn submodule_breakdown_true_when_value_enabled() {
28985 let mut form = blank_form();
28986 form.submodule_breakdown = Some("enabled".to_string());
28987 assert!(apply(&form).discovery.submodule_breakdown);
28988 }
28989
28990 #[test]
28991 fn submodule_breakdown_false_for_wrong_value() {
28992 let mut form = blank_form();
28994 form.submodule_breakdown = Some("on".to_string());
28995 assert!(
28996 !apply(&form).discovery.submodule_breakdown,
28997 "submodule_breakdown only becomes true for the exact value 'enabled'"
28998 );
28999 }
29000
29001 #[test]
29004 fn generated_detection_true_when_enabled() {
29005 let mut form = blank_form();
29006 form.generated_file_detection = Some("enabled".to_string());
29007 assert!(apply(&form).analysis.generated_file_detection);
29008 }
29009
29010 #[test]
29011 fn generated_detection_false_when_disabled() {
29012 let mut form = blank_form();
29013 form.generated_file_detection = Some("disabled".to_string());
29014 assert!(!apply(&form).analysis.generated_file_detection);
29015 }
29016
29017 #[test]
29018 fn generated_detection_true_when_absent() {
29019 assert!(
29021 apply(&blank_form()).analysis.generated_file_detection,
29022 "absent field must default to true (detection on)"
29023 );
29024 }
29025
29026 #[test]
29029 fn minified_detection_false_when_disabled() {
29030 let mut form = blank_form();
29031 form.minified_file_detection = Some("disabled".to_string());
29032 assert!(!apply(&form).analysis.minified_file_detection);
29033 }
29034
29035 #[test]
29036 fn minified_detection_true_when_enabled() {
29037 let mut form = blank_form();
29038 form.minified_file_detection = Some("enabled".to_string());
29039 assert!(apply(&form).analysis.minified_file_detection);
29040 }
29041
29042 #[test]
29043 fn minified_detection_true_when_absent() {
29044 assert!(apply(&blank_form()).analysis.minified_file_detection);
29045 }
29046
29047 #[test]
29050 fn vendor_detection_false_when_disabled() {
29051 let mut form = blank_form();
29052 form.vendor_directory_detection = Some("disabled".to_string());
29053 assert!(!apply(&form).analysis.vendor_directory_detection);
29054 }
29055
29056 #[test]
29057 fn vendor_detection_true_when_enabled() {
29058 let mut form = blank_form();
29059 form.vendor_directory_detection = Some("enabled".to_string());
29060 assert!(apply(&form).analysis.vendor_directory_detection);
29061 }
29062
29063 #[test]
29064 fn vendor_detection_true_when_absent() {
29065 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
29066 }
29067
29068 #[test]
29071 fn lockfiles_false_when_absent() {
29072 assert!(!apply(&blank_form()).analysis.include_lockfiles);
29074 }
29075
29076 #[test]
29077 fn lockfiles_false_when_disabled() {
29078 let mut form = blank_form();
29079 form.include_lockfiles = Some("disabled".to_string());
29080 assert!(!apply(&form).analysis.include_lockfiles);
29081 }
29082
29083 #[test]
29084 fn lockfiles_true_when_enabled() {
29085 let mut form = blank_form();
29086 form.include_lockfiles = Some("enabled".to_string());
29087 assert!(apply(&form).analysis.include_lockfiles);
29088 }
29089
29090 #[test]
29093 fn compiler_directives_true_when_absent() {
29094 assert!(
29095 apply(&blank_form()).analysis.count_compiler_directives,
29096 "absent count_compiler_directives must default to true"
29097 );
29098 }
29099
29100 #[test]
29101 fn compiler_directives_true_when_enabled() {
29102 let mut form = blank_form();
29103 form.count_compiler_directives = Some("enabled".to_string());
29104 assert!(apply(&form).analysis.count_compiler_directives);
29105 }
29106
29107 #[test]
29108 fn compiler_directives_false_when_disabled() {
29109 let mut form = blank_form();
29110 form.count_compiler_directives = Some("disabled".to_string());
29111 assert!(!apply(&form).analysis.count_compiler_directives);
29112 }
29113
29114 #[test]
29117 fn mixed_policy_unchanged_when_absent() {
29118 assert_eq!(
29120 apply(&blank_form()).analysis.mixed_line_policy,
29121 MixedLinePolicy::CodeOnly
29122 );
29123 }
29124
29125 #[test]
29126 fn mixed_policy_code_only() {
29127 let mut form = blank_form();
29128 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
29129 assert_eq!(
29130 apply(&form).analysis.mixed_line_policy,
29131 MixedLinePolicy::CodeOnly
29132 );
29133 }
29134
29135 #[test]
29136 fn mixed_policy_code_and_comment() {
29137 let mut form = blank_form();
29138 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
29139 assert_eq!(
29140 apply(&form).analysis.mixed_line_policy,
29141 MixedLinePolicy::CodeAndComment
29142 );
29143 }
29144
29145 #[test]
29146 fn mixed_policy_comment_only() {
29147 let mut form = blank_form();
29148 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
29149 assert_eq!(
29150 apply(&form).analysis.mixed_line_policy,
29151 MixedLinePolicy::CommentOnly
29152 );
29153 }
29154
29155 #[test]
29156 fn mixed_policy_separate_mixed_category() {
29157 let mut form = blank_form();
29158 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
29159 assert_eq!(
29160 apply(&form).analysis.mixed_line_policy,
29161 MixedLinePolicy::SeparateMixedCategory
29162 );
29163 }
29164
29165 #[test]
29168 fn binary_behavior_skip_when_absent() {
29169 assert_eq!(
29170 apply(&blank_form()).analysis.binary_file_behavior,
29171 BinaryFileBehavior::Skip
29172 );
29173 }
29174
29175 #[test]
29176 fn binary_behavior_skip() {
29177 let mut form = blank_form();
29178 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
29179 assert_eq!(
29180 apply(&form).analysis.binary_file_behavior,
29181 BinaryFileBehavior::Skip
29182 );
29183 }
29184
29185 #[test]
29186 fn binary_behavior_fail() {
29187 let mut form = blank_form();
29188 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
29189 assert_eq!(
29190 apply(&form).analysis.binary_file_behavior,
29191 BinaryFileBehavior::Fail
29192 );
29193 }
29194
29195 #[test]
29198 fn continuation_policy_each_physical_when_absent() {
29199 assert_eq!(
29200 apply(&blank_form()).analysis.continuation_line_policy,
29201 ContinuationLinePolicy::EachPhysicalLine
29202 );
29203 }
29204
29205 #[test]
29206 fn continuation_policy_collapse_to_logical() {
29207 let mut form = blank_form();
29208 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
29209 assert_eq!(
29210 apply(&form).analysis.continuation_line_policy,
29211 ContinuationLinePolicy::CollapseToLogical
29212 );
29213 }
29214
29215 #[test]
29218 fn blank_in_block_comment_count_as_comment_when_absent() {
29219 assert_eq!(
29220 apply(&blank_form()).analysis.blank_in_block_comment_policy,
29221 BlankInBlockCommentPolicy::CountAsComment
29222 );
29223 }
29224
29225 #[test]
29226 fn blank_in_block_comment_count_as_blank() {
29227 let mut form = blank_form();
29228 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
29229 assert_eq!(
29230 apply(&form).analysis.blank_in_block_comment_policy,
29231 BlankInBlockCommentPolicy::CountAsBlank
29232 );
29233 }
29234
29235 #[test]
29238 fn style_threshold_80() {
29239 let mut form = blank_form();
29240 form.style_col_threshold = Some("80".to_string());
29241 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
29242 }
29243
29244 #[test]
29245 fn style_threshold_100() {
29246 let mut form = blank_form();
29247 form.style_col_threshold = Some("100".to_string());
29248 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
29249 }
29250
29251 #[test]
29252 fn style_threshold_120() {
29253 let mut form = blank_form();
29254 form.style_col_threshold = Some("120".to_string());
29255 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
29256 }
29257
29258 #[test]
29259 fn style_threshold_invalid_value_leaves_default() {
29260 let mut cfg = sloc_config::AppConfig::default();
29262 let mut form = blank_form();
29263 form.style_col_threshold = Some("42".to_string());
29264 apply_form_to_config(&mut cfg, &form);
29265 assert_eq!(
29266 cfg.analysis.style_col_threshold, 80,
29267 "invalid threshold must not change config"
29268 );
29269 }
29270
29271 #[test]
29272 fn style_threshold_non_numeric_leaves_default() {
29273 let mut cfg = sloc_config::AppConfig::default();
29274 let mut form = blank_form();
29275 form.style_col_threshold = Some("large".to_string());
29276 apply_form_to_config(&mut cfg, &form);
29277 assert_eq!(cfg.analysis.style_col_threshold, 80);
29278 }
29279
29280 #[test]
29281 fn style_threshold_zero_leaves_default() {
29282 let mut cfg = sloc_config::AppConfig::default();
29283 let mut form = blank_form();
29284 form.style_col_threshold = Some("0".to_string());
29285 apply_form_to_config(&mut cfg, &form);
29286 assert_eq!(cfg.analysis.style_col_threshold, 80);
29287 }
29288
29289 #[test]
29290 fn style_threshold_absent_leaves_default() {
29291 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
29292 }
29293
29294 #[test]
29297 fn coverage_file_none_when_absent() {
29298 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
29299 }
29300
29301 #[test]
29302 fn coverage_file_none_when_whitespace_only() {
29303 let mut form = blank_form();
29304 form.coverage_file = Some(" ".to_string());
29305 assert!(
29306 apply(&form).analysis.coverage_file.is_none(),
29307 "whitespace-only coverage_file must be treated as None"
29308 );
29309 }
29310
29311 #[test]
29312 fn coverage_file_set_when_non_empty() {
29313 let mut form = blank_form();
29314 form.coverage_file = Some("coverage/lcov.info".to_string());
29315 assert_eq!(
29316 apply(&form).analysis.coverage_file,
29317 Some(std::path::PathBuf::from("coverage/lcov.info"))
29318 );
29319 }
29320
29321 #[test]
29322 fn coverage_file_trims_whitespace() {
29323 let mut form = blank_form();
29324 form.coverage_file = Some(" coverage/lcov.info ".to_string());
29325 assert_eq!(
29326 apply(&form).analysis.coverage_file,
29327 Some(std::path::PathBuf::from("coverage/lcov.info"))
29328 );
29329 }
29330
29331 #[test]
29334 fn report_title_unchanged_when_absent() {
29335 let original = sloc_config::AppConfig::default().reporting.report_title;
29336 assert_eq!(apply(&blank_form()).reporting.report_title, original);
29337 }
29338
29339 #[test]
29340 fn report_title_unchanged_when_whitespace_only() {
29341 let original = sloc_config::AppConfig::default().reporting.report_title;
29342 let mut form = blank_form();
29343 form.report_title = Some(" ".to_string());
29344 assert_eq!(
29345 apply(&form).reporting.report_title,
29346 original,
29347 "whitespace-only title must not overwrite the default"
29348 );
29349 }
29350
29351 #[test]
29352 fn report_title_updated_and_trimmed() {
29353 let mut form = blank_form();
29354 form.report_title = Some(" My Project ".to_string());
29355 assert_eq!(apply(&form).reporting.report_title, "My Project");
29356 }
29357
29358 #[test]
29361 fn header_footer_none_when_absent() {
29362 assert!(apply(&blank_form())
29363 .reporting
29364 .report_header_footer
29365 .is_none());
29366 }
29367
29368 #[test]
29369 fn header_footer_none_when_whitespace_only() {
29370 let mut form = blank_form();
29371 form.report_header_footer = Some(" ".to_string());
29372 assert!(apply(&form).reporting.report_header_footer.is_none());
29373 }
29374
29375 #[test]
29376 fn header_footer_set_and_trimmed() {
29377 let mut form = blank_form();
29378 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
29379 assert_eq!(
29380 apply(&form).reporting.report_header_footer,
29381 Some("Confidential — Internal Use".to_string())
29382 );
29383 }
29384
29385 #[test]
29388 fn include_globs_empty_when_absent() {
29389 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
29390 }
29391
29392 #[test]
29393 fn include_globs_newline_separated() {
29394 let mut form = blank_form();
29395 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
29396 assert_eq!(
29397 apply(&form).discovery.include_globs,
29398 vec!["src/**/*.rs", "tests/**/*.rs"]
29399 );
29400 }
29401
29402 #[test]
29403 fn exclude_globs_comma_separated() {
29404 let mut form = blank_form();
29405 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
29406 assert_eq!(
29407 apply(&form).discovery.exclude_globs,
29408 vec!["vendor/**", "node_modules/**"]
29409 );
29410 }
29411
29412 #[test]
29413 fn globs_mixed_separators() {
29414 let mut form = blank_form();
29415 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
29416 assert_eq!(
29417 apply(&form).discovery.exclude_globs,
29418 vec!["a/**", "b/**", "c/**"]
29419 );
29420 }
29421
29422 #[test]
29425 fn split_patterns_none_is_empty() {
29426 assert!(split_patterns(None).is_empty());
29427 }
29428
29429 #[test]
29430 fn split_patterns_empty_string_is_empty() {
29431 assert!(split_patterns(Some("")).is_empty());
29432 }
29433
29434 #[test]
29435 fn split_patterns_whitespace_only_is_empty() {
29436 assert!(split_patterns(Some(" \n \n ")).is_empty());
29437 }
29438
29439 #[test]
29440 fn split_patterns_newlines() {
29441 assert_eq!(
29442 split_patterns(Some("a/**\nb/**\nc/**")),
29443 vec!["a/**", "b/**", "c/**"]
29444 );
29445 }
29446
29447 #[test]
29448 fn split_patterns_commas() {
29449 assert_eq!(
29450 split_patterns(Some("a/**,b/**,c/**")),
29451 vec!["a/**", "b/**", "c/**"]
29452 );
29453 }
29454
29455 #[test]
29456 fn split_patterns_mixed() {
29457 assert_eq!(
29458 split_patterns(Some("a/**\nb/**,c/**")),
29459 vec!["a/**", "b/**", "c/**"]
29460 );
29461 }
29462
29463 #[test]
29464 fn split_patterns_trims_whitespace() {
29465 assert_eq!(
29466 split_patterns(Some(" a/** \n b/** ")),
29467 vec!["a/**", "b/**"]
29468 );
29469 }
29470
29471 #[test]
29472 fn split_patterns_filters_empty_entries() {
29473 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
29474 }
29475
29476 #[test]
29477 fn split_patterns_single_entry() {
29478 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
29479 }
29480}
29481
29482#[cfg(test)]
29483mod utility_tests {
29484 use super::*;
29485 use std::net::IpAddr;
29486 use std::time::Duration;
29487
29488 #[test]
29491 fn sanitize_simple_name() {
29492 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
29493 }
29494
29495 #[test]
29496 fn sanitize_uppercased_lowercased() {
29497 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
29498 }
29499
29500 #[test]
29501 fn sanitize_path_extracts_filename() {
29502 assert_eq!(
29503 sanitize_project_label("/home/user/my-project"),
29504 "my-project"
29505 );
29506 }
29507
29508 #[test]
29509 fn sanitize_path_uses_last_component() {
29510 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
29511 }
29512
29513 #[test]
29514 fn sanitize_spaces_become_hyphens() {
29515 assert_eq!(sanitize_project_label("my project"), "my-project");
29516 }
29517
29518 #[test]
29519 fn sanitize_non_ascii_become_hyphens() {
29520 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
29521 }
29522
29523 #[test]
29524 fn sanitize_all_special_chars_gives_project() {
29525 assert_eq!(sanitize_project_label("!@#$%^"), "project");
29526 }
29527
29528 #[test]
29529 fn sanitize_empty_string_gives_project() {
29530 assert_eq!(sanitize_project_label(""), "project");
29531 }
29532
29533 #[test]
29534 fn sanitize_leading_trailing_hyphens_stripped() {
29535 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
29536 }
29537
29538 #[test]
29539 fn sanitize_alphanumeric_preserved() {
29540 assert_eq!(sanitize_project_label("repo123"), "repo123");
29541 }
29542
29543 #[test]
29544 fn sanitize_dots_become_hyphens() {
29545 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
29546 }
29547
29548 #[test]
29549 fn sanitize_mixed_slashes_uses_filename() {
29550 assert_eq!(sanitize_project_label("project-name"), "project-name");
29552 }
29553
29554 #[test]
29557 fn rate_limiter_allows_first_request() {
29558 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
29559 let ip: IpAddr = "127.0.0.1".parse().unwrap();
29560 assert!(rl.is_allowed(ip));
29561 }
29562
29563 #[test]
29564 fn rate_limiter_blocks_after_limit_reached() {
29565 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
29566 let ip: IpAddr = "10.0.0.1".parse().unwrap();
29567 assert!(rl.is_allowed(ip));
29568 assert!(rl.is_allowed(ip));
29569 assert!(rl.is_allowed(ip));
29570 assert!(!rl.is_allowed(ip), "4th request must be blocked");
29571 }
29572
29573 #[test]
29574 fn rate_limiter_allows_requests_up_to_limit() {
29575 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
29576 let ip: IpAddr = "10.0.0.2".parse().unwrap();
29577 for _ in 0..5 {
29578 assert!(rl.is_allowed(ip));
29579 }
29580 assert!(!rl.is_allowed(ip), "6th request must be blocked");
29581 }
29582
29583 #[test]
29584 fn rate_limiter_different_ips_are_independent() {
29585 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
29586 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
29587 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
29588 assert!(rl.is_allowed(ip1));
29589 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
29590 assert!(rl.is_allowed(ip2), "ip2 must be independent");
29591 }
29592
29593 #[test]
29594 fn rate_limiter_auth_failure_not_locked_below_threshold() {
29595 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29596 let ip: IpAddr = "10.0.0.3".parse().unwrap();
29597 rl.record_auth_failure(ip);
29598 rl.record_auth_failure(ip);
29599 assert!(
29600 !rl.is_auth_locked_out(ip),
29601 "not locked at 2 failures when threshold is 3"
29602 );
29603 }
29604
29605 #[test]
29606 fn rate_limiter_auth_failure_locked_at_threshold() {
29607 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29608 let ip: IpAddr = "10.0.0.4".parse().unwrap();
29609 rl.record_auth_failure(ip);
29610 rl.record_auth_failure(ip);
29611 rl.record_auth_failure(ip);
29612 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
29613 }
29614
29615 #[test]
29616 fn rate_limiter_auth_failure_different_ips_independent() {
29617 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
29618 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
29619 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
29620 rl.record_auth_failure(ip1);
29621 rl.record_auth_failure(ip1);
29622 assert!(rl.is_auth_locked_out(ip1));
29623 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
29624 }
29625
29626 #[test]
29627 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
29628 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
29629 let ip: IpAddr = "127.0.0.2".parse().unwrap();
29630 for _ in 0..100 {
29631 assert!(rl.is_allowed(ip));
29632 }
29633 }
29634
29635 #[test]
29638 fn strip_unc_plain_path_unchanged() {
29639 let p = PathBuf::from("C:\\Users\\user\\project");
29640 let result = strip_unc_prefix(p.clone());
29641 assert_eq!(result, p);
29642 }
29643
29644 #[test]
29645 fn strip_unc_with_drive_prefix_stripped() {
29646 let p = PathBuf::from(r"\\?\C:\Users\user\project");
29647 let result = strip_unc_prefix(p);
29648 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
29649 }
29650
29651 #[test]
29652 fn strip_unc_with_network_prefix_stripped() {
29653 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
29654 let result = strip_unc_prefix(p);
29655 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
29656 }
29657
29658 #[test]
29659 fn strip_unc_linux_path_unchanged() {
29660 let p = PathBuf::from("/home/user/project");
29661 let result = strip_unc_prefix(p.clone());
29662 assert_eq!(result, p);
29663 }
29664
29665 #[test]
29668 fn remote_to_commit_url_github_https() {
29669 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
29670 assert_eq!(
29671 url,
29672 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29673 );
29674 }
29675
29676 #[test]
29677 fn remote_to_commit_url_github_ssh() {
29678 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
29679 assert_eq!(
29680 url,
29681 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29682 );
29683 }
29684
29685 #[test]
29686 fn remote_to_commit_url_gitlab_uses_dash_commit() {
29687 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
29688 assert_eq!(
29689 url,
29690 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
29691 );
29692 }
29693
29694 #[test]
29695 fn remote_to_commit_url_bitbucket_uses_commits() {
29696 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
29697 assert_eq!(
29698 url,
29699 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
29700 );
29701 }
29702
29703 #[test]
29704 fn remote_to_commit_url_unknown_scheme_returns_none() {
29705 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
29706 assert!(url.is_none());
29707 }
29708
29709 #[test]
29710 fn remote_to_commit_url_ssh_gitlab() {
29711 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
29712 assert!(url.is_some());
29713 let u = url.unwrap();
29714 assert!(
29715 u.contains("/-/commit/sha123"),
29716 "gitlab ssh must use /-/commit/"
29717 );
29718 }
29719
29720 #[test]
29723 fn git_clone_dest_github_url_produces_safe_name() {
29724 let dir = PathBuf::from("/tmp/clones");
29725 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29726 let name = dest.file_name().unwrap().to_string_lossy();
29727 assert!(!name.is_empty());
29728 assert!(
29729 name.chars()
29730 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
29731 "clone dest must only contain safe chars, got: {name}"
29732 );
29733 }
29734
29735 #[test]
29736 fn git_clone_dest_is_inside_clones_dir() {
29737 let dir = PathBuf::from("/tmp/clones");
29738 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29739 assert!(
29740 dest.starts_with(&dir),
29741 "clone dest must be inside clones_dir"
29742 );
29743 }
29744
29745 #[test]
29746 fn git_clone_dest_truncates_to_80_chars_max() {
29747 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
29748 let dir = PathBuf::from("/tmp/clones");
29749 let dest = git_clone_dest(&long_url, &dir);
29750 let name = dest.file_name().unwrap().to_string_lossy();
29751 assert!(
29752 name.len() <= 80,
29753 "clone dest name must be at most 80 chars, got {} chars: {name}",
29754 name.len()
29755 );
29756 }
29757
29758 #[test]
29759 fn git_clone_dest_special_chars_replaced_with_underscore() {
29760 let dir = PathBuf::from("/tmp/clones");
29761 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
29762 let name = dest.file_name().unwrap().to_string_lossy();
29763 assert!(
29764 !name.contains('@') && !name.contains(':') && !name.contains('/'),
29765 "special chars must be replaced in clone dest, got: {name}"
29766 );
29767 }
29768
29769 #[test]
29770 fn git_clone_dest_different_urls_differ() {
29771 let dir = PathBuf::from("/tmp/clones");
29772 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
29773 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
29774 assert_ne!(
29775 a, b,
29776 "different repos must produce different clone dest names"
29777 );
29778 }
29779
29780 #[test]
29781 fn git_clone_dest_same_url_same_result() {
29782 let dir = PathBuf::from("/tmp/clones");
29783 let url = "https://github.com/owner/repo.git";
29784 assert_eq!(
29785 git_clone_dest(url, &dir),
29786 git_clone_dest(url, &dir),
29787 "same URL must always give same clone dest"
29788 );
29789 }
29790
29791 #[test]
29794 fn fmt_delta_positive_has_plus_prefix() {
29795 assert_eq!(fmt_delta(5), "+5");
29796 }
29797
29798 #[test]
29799 fn fmt_delta_negative_no_plus_prefix() {
29800 assert_eq!(fmt_delta(-3), "-3");
29801 }
29802
29803 #[test]
29804 fn fmt_delta_zero() {
29805 assert_eq!(fmt_delta(0), "0");
29806 }
29807
29808 #[test]
29811 fn delta_class_positive_is_pos() {
29812 assert_eq!(delta_class(1), "pos");
29813 }
29814
29815 #[test]
29816 fn delta_class_negative_is_neg() {
29817 assert_eq!(delta_class(-1), "neg");
29818 }
29819
29820 #[test]
29821 fn delta_class_zero_is_zero_class() {
29822 assert_eq!(delta_class(0), "zero");
29823 }
29824
29825 #[test]
29828 fn fmt_pct_zero_baseline_returns_em_dash() {
29829 assert_eq!(fmt_pct(100, 0), "\u{2014}");
29830 }
29831
29832 #[test]
29833 fn fmt_pct_positive_delta_has_plus_sign() {
29834 let result = fmt_pct(10, 100);
29835 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
29836 }
29837
29838 #[test]
29839 fn fmt_pct_negative_delta_no_plus_sign() {
29840 let result = fmt_pct(-10, 100);
29841 assert!(!result.starts_with('+'), "unexpected + in: {result}");
29842 assert!(result.contains('%'));
29843 }
29844
29845 #[test]
29846 fn fmt_pct_near_zero_returns_pm_zero() {
29847 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
29848 }
29849
29850 #[test]
29853 fn summary_delta_no_prev_returns_dash_na() {
29854 let (display, class) = summary_delta(10, None);
29855 assert_eq!(display, "\u{2014}");
29856 assert_eq!(class, "na");
29857 }
29858
29859 #[test]
29860 fn summary_delta_increase_is_positive() {
29861 let (display, class) = summary_delta(15, Some(10));
29862 assert_eq!(display, "+5");
29863 assert_eq!(class, "pos");
29864 }
29865
29866 #[test]
29867 fn summary_delta_decrease_is_negative() {
29868 let (display, class) = summary_delta(5, Some(10));
29869 assert_eq!(display, "-5");
29870 assert_eq!(class, "neg");
29871 }
29872
29873 #[test]
29876 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
29877 use chrono::Datelike;
29878 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
29879 assert_eq!(d.year(), 2024);
29880 assert_eq!(d.month(), 1);
29881 assert_eq!(d.weekday(), chrono::Weekday::Mon);
29882 assert!(d.day() <= 7);
29883 }
29884
29885 #[test]
29886 fn nth_weekday_second_sunday_march_2024_is_10th() {
29887 use chrono::Datelike;
29888 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
29889 assert_eq!(d.weekday(), chrono::Weekday::Sun);
29890 assert_eq!(d.month(), 3);
29891 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
29892 }
29893
29894 #[test]
29897 fn is_pacific_dst_july_is_true() {
29898 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29899 assert!(is_pacific_dst(dt), "July must be PDT");
29900 }
29901
29902 #[test]
29903 fn is_pacific_dst_january_is_false() {
29904 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29905 assert!(!is_pacific_dst(dt), "January must be PST");
29906 }
29907
29908 #[test]
29909 fn fmt_la_time_summer_shows_pdt() {
29910 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29911 let result = fmt_la_time(dt);
29912 assert!(
29913 result.ends_with("PDT"),
29914 "summer must use PDT, got: {result}"
29915 );
29916 }
29917
29918 #[test]
29919 fn fmt_la_time_winter_shows_pst() {
29920 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29921 let result = fmt_la_time(dt);
29922 assert!(
29923 result.ends_with("PST"),
29924 "winter must use PST, got: {result}"
29925 );
29926 }
29927
29928 #[test]
29929 fn fmt_la_time_meta_summer_shows_pdt() {
29930 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
29931 let result = fmt_la_time_meta(dt);
29932 assert!(
29933 result.ends_with("PDT"),
29934 "meta summer must use PDT, got: {result}"
29935 );
29936 }
29937
29938 #[test]
29939 fn fmt_la_time_meta_winter_shows_pst() {
29940 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
29941 let result = fmt_la_time_meta(dt);
29942 assert!(
29943 result.ends_with("PST"),
29944 "meta winter must use PST, got: {result}"
29945 );
29946 }
29947
29948 #[test]
29951 fn fmt_git_date_valid_iso_returns_some() {
29952 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
29953 }
29954
29955 #[test]
29956 fn fmt_git_date_invalid_returns_none() {
29957 assert!(fmt_git_date("not-a-date").is_none());
29958 }
29959
29960 #[test]
29963 fn format_number_zero() {
29964 assert_eq!(format_number(0), "0");
29965 }
29966
29967 #[test]
29968 fn format_number_three_digits_no_comma() {
29969 assert_eq!(format_number(999), "999");
29970 }
29971
29972 #[test]
29973 fn format_number_four_digits_has_comma() {
29974 assert_eq!(format_number(1000), "1,000");
29975 }
29976
29977 #[test]
29978 fn format_number_seven_digits_two_commas() {
29979 assert_eq!(format_number(1_234_567), "1,234,567");
29980 }
29981
29982 #[test]
29983 fn format_number_one_million() {
29984 assert_eq!(format_number(1_000_000), "1,000,000");
29985 }
29986
29987 #[test]
29990 fn badge_text_px_empty_is_zero() {
29991 assert_eq!(badge_text_px(""), 0);
29992 }
29993
29994 #[test]
29995 fn badge_text_px_narrow_chars_smaller_than_normal() {
29996 assert!(
29997 badge_text_px("if") < badge_text_px("ab"),
29998 "'if' must be narrower than 'ab'"
29999 );
30000 }
30001
30002 #[test]
30003 fn badge_text_px_m_is_wider_than_a() {
30004 assert!(
30005 badge_text_px("m") > badge_text_px("a"),
30006 "'m' must be wider than 'a'"
30007 );
30008 }
30009
30010 #[test]
30011 fn render_badge_svg_contains_label_and_value() {
30012 let svg = render_badge_svg("coverage", "95%", "#4c1");
30013 assert!(svg.contains("coverage") && svg.contains("95%"));
30014 }
30015
30016 #[test]
30017 fn render_badge_svg_contains_color() {
30018 let svg = render_badge_svg("sloc", "12K", "#e05d44");
30019 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
30020 }
30021
30022 #[test]
30023 fn render_badge_svg_escapes_ampersand_in_label() {
30024 let svg = render_badge_svg("test&label", "ok", "#4c1");
30025 assert!(svg.contains("&") && !svg.contains("test&label"));
30026 }
30027
30028 #[test]
30031 fn build_pdf_filename_slugifies_title() {
30032 let name = build_pdf_filename("My Project Report", "abc-def-1234");
30033 assert!(
30034 name.starts_with("my_project_report_")
30035 && std::path::Path::new(&name)
30036 .extension()
30037 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30038 );
30039 }
30040
30041 #[test]
30042 fn build_pdf_filename_uses_last_run_id_segment() {
30043 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
30044 assert!(name.contains("ABCD"), "must use last segment of run_id");
30045 }
30046
30047 #[test]
30048 fn build_pdf_filename_empty_title_uses_report_prefix() {
30049 let name = build_pdf_filename("", "abc-def-9999");
30050 assert!(
30051 name.starts_with("report_")
30052 && std::path::Path::new(&name)
30053 .extension()
30054 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30055 );
30056 }
30057
30058 #[test]
30061 fn swap_chart_js_replaces_inline_block() {
30062 let html = "<html><head><script>// inline source</script></head><body></body></html>";
30063 let result = swap_inline_chart_js_for_static(html.to_string());
30064 assert!(result.contains(r#"src="/static/chart-report.js""#));
30065 assert!(!result.contains("inline source"));
30066 }
30067
30068 #[test]
30069 fn swap_chart_js_no_head_returns_unchanged() {
30070 let html = "<body>no head here</body>";
30071 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
30072 }
30073
30074 #[test]
30075 fn swap_chart_js_no_script_in_head_unchanged() {
30076 let html = "<html><head><style>.x{}</style></head><body></body></html>";
30077 let result = swap_inline_chart_js_for_static(html.to_string());
30078 assert!(!result.contains("chart-report.js"));
30079 }
30080
30081 #[test]
30084 fn patch_html_nonce_replaces_old_nonce() {
30085 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
30086 let result = patch_html_nonce(html, "new-nonce-456");
30087 assert!(result.contains(r#"nonce="new-nonce-456""#));
30088 assert!(!result.contains("old-nonce-123"));
30089 }
30090
30091 #[test]
30092 fn patch_html_nonce_injects_into_bare_style() {
30093 let html = "<style>body{color:red;}</style>";
30094 let result = patch_html_nonce(html, "fresh-nonce");
30095 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
30096 }
30097
30098 #[test]
30099 fn patch_html_nonce_injects_into_bare_script() {
30100 let html = "<script>console.log(1);</script>";
30101 let result = patch_html_nonce(html, "abc");
30102 assert!(result.contains(r#"<script nonce="abc">"#));
30103 }
30104
30105 #[test]
30108 fn is_html_report_file_result_html_matches() {
30109 let dir = tempfile::tempdir().unwrap();
30110 let path = dir.path().join("result_20240101.html");
30111 std::fs::write(&path, b"<html></html>").unwrap();
30112 assert!(is_html_report_file(&path));
30113 }
30114
30115 #[test]
30116 fn is_html_report_file_report_html_matches() {
30117 let dir = tempfile::tempdir().unwrap();
30118 let path = dir.path().join("report_abc.html");
30119 std::fs::write(&path, b"<html></html>").unwrap();
30120 assert!(is_html_report_file(&path));
30121 }
30122
30123 #[test]
30124 fn is_html_report_file_index_html_does_not_match() {
30125 let dir = tempfile::tempdir().unwrap();
30126 let path = dir.path().join("index.html");
30127 std::fs::write(&path, b"<html></html>").unwrap();
30128 assert!(!is_html_report_file(&path));
30129 }
30130
30131 #[test]
30132 fn is_html_report_file_nonexistent_returns_false() {
30133 assert!(!is_html_report_file(Path::new(
30134 "/nonexistent/result_xyz.html"
30135 )));
30136 }
30137
30138 #[test]
30139 fn find_html_report_in_dir_finds_result_html() {
30140 let dir = tempfile::tempdir().unwrap();
30141 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
30142 assert!(find_html_report_in_dir(dir.path()).is_some());
30143 }
30144
30145 #[test]
30146 fn find_html_report_in_dir_empty_returns_none() {
30147 let dir = tempfile::tempdir().unwrap();
30148 assert!(find_html_report_in_dir(dir.path()).is_none());
30149 }
30150
30151 #[test]
30152 fn find_html_report_in_tree_finds_in_subdir() {
30153 let dir = tempfile::tempdir().unwrap();
30154 let subdir = dir.path().join("run-001");
30155 std::fs::create_dir_all(&subdir).unwrap();
30156 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
30157 assert!(find_html_report_in_tree(dir.path()).is_some());
30158 }
30159
30160 #[test]
30163 fn derive_project_label_with_git_repo_and_ref() {
30164 let label = derive_project_label(
30165 Some("https://github.com/owner/my-repo.git"),
30166 Some("main"),
30167 "/fallback/path",
30168 );
30169 assert!(!label.is_empty(), "label must not be empty");
30170 assert!(
30171 label.contains("my") || label.contains("repo"),
30172 "got: {label}"
30173 );
30174 }
30175
30176 #[test]
30177 fn derive_project_label_fallback_to_path() {
30178 let label = derive_project_label(None, None, "/path/to/myproject");
30179 assert_eq!(label, "myproject");
30180 }
30181
30182 #[test]
30183 fn derive_project_label_empty_git_fields_use_path() {
30184 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
30185 assert_eq!(label, "cool-app");
30186 }
30187
30188 #[test]
30191 fn derive_file_stem_with_commit_appends_sha() {
30192 assert_eq!(
30193 derive_file_stem("myproject", Some("a1b2c3")),
30194 "myproject_a1b2c3"
30195 );
30196 }
30197
30198 #[test]
30199 fn derive_file_stem_without_commit_returns_label() {
30200 assert_eq!(derive_file_stem("myproject", None), "myproject");
30201 }
30202
30203 #[test]
30204 fn derive_file_stem_empty_commit_returns_label() {
30205 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
30206 }
30207
30208 #[test]
30211 fn split_patterns_none_is_empty() {
30212 assert!(split_patterns(None).is_empty());
30213 }
30214
30215 #[test]
30216 fn split_patterns_empty_string_is_empty() {
30217 assert!(split_patterns(Some("")).is_empty());
30218 }
30219
30220 #[test]
30221 fn split_patterns_comma_separated() {
30222 assert_eq!(
30223 split_patterns(Some("foo,bar,baz")),
30224 vec!["foo", "bar", "baz"]
30225 );
30226 }
30227
30228 #[test]
30229 fn split_patterns_newline_separated() {
30230 assert_eq!(
30231 split_patterns(Some("foo\nbar\nbaz")),
30232 vec!["foo", "bar", "baz"]
30233 );
30234 }
30235
30236 #[test]
30237 fn split_patterns_trims_whitespace() {
30238 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
30239 }
30240
30241 #[test]
30244 fn make_git_label_empty_repo_empty_result() {
30245 assert_eq!(make_git_label("", "main"), "");
30246 }
30247
30248 #[test]
30249 fn make_git_label_empty_ref_empty_result() {
30250 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
30251 }
30252
30253 #[test]
30254 fn make_git_label_basic_format() {
30255 assert_eq!(
30256 make_git_label("https://github.com/owner/my-repo.git", "main"),
30257 "my-repo_at_main_sloc"
30258 );
30259 }
30260
30261 #[test]
30262 fn make_git_label_slash_in_ref_replaced() {
30263 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
30264 assert!(
30265 !label.contains('/'),
30266 "slash in ref must be replaced: {label}"
30267 );
30268 }
30269
30270 #[test]
30273 fn format_dir_size_bytes() {
30274 assert_eq!(format_dir_size(500), "500 B");
30275 }
30276
30277 #[test]
30278 fn format_dir_size_kilobytes() {
30279 assert_eq!(format_dir_size(2048), "2 KB");
30280 }
30281
30282 #[test]
30283 fn format_dir_size_megabytes() {
30284 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
30285 }
30286
30287 #[test]
30288 fn format_dir_size_gigabytes() {
30289 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
30290 }
30291
30292 #[test]
30293 fn format_dir_size_zero() {
30294 assert_eq!(format_dir_size(0), "0 B");
30295 }
30296
30297 #[test]
30300 fn civil_from_days_epoch() {
30301 assert_eq!(civil_from_days(0), (1970, 1, 1));
30302 }
30303
30304 #[test]
30305 fn civil_from_days_one_year_later() {
30306 assert_eq!(civil_from_days(365), (1971, 1, 1));
30307 }
30308
30309 #[test]
30310 fn civil_from_days_31_days_is_feb_1_1970() {
30311 assert_eq!(civil_from_days(31), (1970, 2, 1));
30312 }
30313
30314 #[test]
30317 fn format_system_time_unix_epoch_formats_correctly() {
30318 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
30319 }
30320
30321 #[test]
30322 fn format_system_time_31_days_after_epoch() {
30323 let t = UNIX_EPOCH + Duration::from_hours(744);
30324 assert_eq!(format_system_time(t), "1970-02-01 00:00");
30325 }
30326
30327 #[test]
30328 fn format_system_time_before_epoch_returns_dash() {
30329 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
30330 assert_eq!(format_system_time(before), "-");
30331 }
30332 }
30333
30334 #[test]
30337 fn detect_language_name_dot_c() {
30338 assert_eq!(detect_language_name("main.c"), Some("C"));
30339 }
30340
30341 #[test]
30342 fn detect_language_name_dot_h() {
30343 assert_eq!(detect_language_name("defs.h"), Some("C"));
30344 }
30345
30346 #[test]
30347 fn detect_language_name_dot_cpp() {
30348 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
30349 }
30350
30351 #[test]
30352 fn detect_language_name_dot_py() {
30353 assert_eq!(detect_language_name("script.py"), Some("Python"));
30354 }
30355
30356 #[test]
30357 fn detect_language_name_dot_ps1() {
30358 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
30359 }
30360
30361 #[test]
30362 fn detect_language_name_dot_cs() {
30363 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
30364 }
30365
30366 #[test]
30367 fn detect_language_name_dot_sh() {
30368 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
30369 }
30370
30371 #[test]
30372 fn detect_language_name_unknown_txt() {
30373 assert_eq!(detect_language_name("notes.txt"), None);
30374 }
30375
30376 #[test]
30379 fn language_icon_file_c() {
30380 assert_eq!(language_icon_file("C"), Some("c.png"));
30381 }
30382
30383 #[test]
30384 fn language_icon_file_python() {
30385 assert_eq!(language_icon_file("Python"), Some("python.png"));
30386 }
30387
30388 #[test]
30389 fn language_icon_file_dockerfile() {
30390 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
30391 }
30392
30393 #[test]
30394 fn language_icon_file_rust_is_none() {
30395 assert!(language_icon_file("Rust").is_none());
30396 }
30397
30398 #[test]
30399 fn language_icon_file_unknown_is_none() {
30400 assert!(language_icon_file("Fortran").is_none());
30401 }
30402
30403 #[test]
30406 fn language_inline_svg_rust_is_svg() {
30407 let svg = language_inline_svg("Rust").unwrap();
30408 assert!(svg.starts_with("<svg"));
30409 }
30410
30411 #[test]
30412 fn language_inline_svg_typescript_is_some() {
30413 assert!(language_inline_svg("TypeScript").is_some());
30414 }
30415
30416 #[test]
30417 fn language_inline_svg_unknown_is_none() {
30418 assert!(language_inline_svg("Fortran").is_none());
30419 }
30420
30421 #[test]
30424 fn classify_preview_file_c_supported() {
30425 assert!(matches!(
30426 classify_preview_file("main.c"),
30427 PreviewKind::Supported
30428 ));
30429 }
30430
30431 #[test]
30432 fn classify_preview_file_python_supported() {
30433 assert!(matches!(
30434 classify_preview_file("script.py"),
30435 PreviewKind::Supported
30436 ));
30437 }
30438
30439 #[test]
30440 fn classify_preview_file_png_skipped() {
30441 assert!(matches!(
30442 classify_preview_file("image.png"),
30443 PreviewKind::Skipped
30444 ));
30445 }
30446
30447 #[test]
30448 fn classify_preview_file_zip_skipped() {
30449 assert!(matches!(
30450 classify_preview_file("archive.zip"),
30451 PreviewKind::Skipped
30452 ));
30453 }
30454
30455 #[test]
30456 fn classify_preview_file_min_js_skipped() {
30457 assert!(matches!(
30458 classify_preview_file("bundle.min.js"),
30459 PreviewKind::Skipped
30460 ));
30461 }
30462
30463 #[test]
30464 fn classify_preview_file_rs_unsupported() {
30465 assert!(matches!(
30466 classify_preview_file("main.rs"),
30467 PreviewKind::Unsupported
30468 ));
30469 }
30470
30471 #[test]
30474 fn preview_relative_path_strips_root() {
30475 let root = PathBuf::from("/project");
30476 let path = PathBuf::from("/project/src/main.c");
30477 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
30478 }
30479
30480 #[test]
30481 fn preview_relative_path_unrooted_includes_filename() {
30482 let root = PathBuf::from("/other");
30483 let path = PathBuf::from("/project/src/main.c");
30484 let result = preview_relative_path(&root, &path);
30485 assert!(result.contains("main.c"));
30486 }
30487
30488 #[test]
30489 fn preview_relative_path_uses_forward_slashes() {
30490 let root = PathBuf::from("/project");
30491 let path = PathBuf::from("/project/a/b/c.py");
30492 assert!(!preview_relative_path(&root, &path).contains('\\'));
30493 }
30494
30495 #[test]
30498 fn wildcard_match_exact_equal() {
30499 assert!(wildcard_match("foo", "foo"));
30500 }
30501
30502 #[test]
30503 fn wildcard_match_exact_mismatch() {
30504 assert!(!wildcard_match("foo", "bar"));
30505 }
30506
30507 #[test]
30508 fn wildcard_match_star_suffix() {
30509 assert!(wildcard_match("*.rs", "main.rs"));
30510 }
30511
30512 #[test]
30513 fn wildcard_match_star_middle_requires_suffix() {
30514 assert!(!wildcard_match("a*b", "ac"));
30515 }
30516
30517 #[test]
30518 fn wildcard_match_question_mark_single_char() {
30519 assert!(wildcard_match("f?o", "foo"));
30520 }
30521
30522 #[test]
30523 fn wildcard_match_double_star_nested() {
30524 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
30525 }
30526
30527 #[test]
30528 fn wildcard_match_star_directory_entry() {
30529 assert!(wildcard_match("vendor/*", "vendor/crate"));
30530 }
30531
30532 #[test]
30533 fn wildcard_match_no_cross_prefix() {
30534 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
30535 }
30536
30537 #[test]
30540 fn should_skip_empty_relative_is_false() {
30541 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
30542 }
30543
30544 #[test]
30545 fn should_skip_matching_pattern() {
30546 assert!(should_skip_preview_directory(
30547 "vendor",
30548 &["vendor".to_string()]
30549 ));
30550 }
30551
30552 #[test]
30553 fn should_skip_non_matching() {
30554 assert!(!should_skip_preview_directory(
30555 "src",
30556 &["vendor".to_string()]
30557 ));
30558 }
30559
30560 #[test]
30561 fn should_skip_wildcard_prefix() {
30562 assert!(should_skip_preview_directory(
30563 "target/debug",
30564 &["target*".to_string()]
30565 ));
30566 }
30567
30568 #[test]
30571 fn should_include_empty_relative_always_true() {
30572 assert!(should_include_preview_file("", &[], &[]));
30573 }
30574
30575 #[test]
30576 fn should_include_no_patterns_includes_all() {
30577 assert!(should_include_preview_file("src/main.c", &[], &[]));
30578 }
30579
30580 #[test]
30581 fn should_include_excluded_by_pattern() {
30582 assert!(!should_include_preview_file(
30583 "vendor/lib.c",
30584 &[],
30585 &["vendor/*".to_string()]
30586 ));
30587 }
30588
30589 #[test]
30590 fn should_include_include_pattern_filters() {
30591 assert!(!should_include_preview_file(
30592 "tests/test_foo.c",
30593 &["src/*".to_string()],
30594 &[]
30595 ));
30596 }
30597
30598 #[test]
30601 fn escape_html_ampersand() {
30602 assert_eq!(escape_html("a&b"), "a&b");
30603 }
30604
30605 #[test]
30606 fn escape_html_angle_brackets() {
30607 assert_eq!(escape_html("<br>"), "<br>");
30608 }
30609
30610 #[test]
30611 fn escape_html_double_quote() {
30612 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
30613 }
30614
30615 #[test]
30616 fn escape_html_single_quote() {
30617 assert_eq!(escape_html("it's"), "it's");
30618 }
30619
30620 #[test]
30621 fn escape_html_plain_text_unchanged() {
30622 assert_eq!(escape_html("hello world"), "hello world");
30623 }
30624
30625 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
30628 sloc_core::ScanComparison {
30629 summary: sloc_core::SummaryDelta {
30630 baseline_run_id: "base".to_string(),
30631 current_run_id: "curr".to_string(),
30632 baseline_timestamp: chrono::Utc::now(),
30633 current_timestamp: chrono::Utc::now(),
30634 baseline_files: 4,
30635 current_files: 4,
30636 files_analyzed_delta: 0,
30637 baseline_code: 330,
30638 current_code: 400,
30639 code_lines_delta: 70,
30640 baseline_comments: 0,
30641 current_comments: 0,
30642 comment_lines_delta: 0,
30643 blank_lines_delta: 0,
30644 total_lines_delta: 70,
30645 coverage_lines_hit_delta: None,
30646 coverage_line_pct_delta: None,
30647 baseline_coverage_line_pct: None,
30648 current_coverage_line_pct: None,
30649 },
30650 file_deltas: vec![
30651 sloc_core::FileDelta {
30652 relative_path: "added.rs".to_string(),
30653 language: Some("Rust".to_string()),
30654 status: FileChangeStatus::Added,
30655 baseline_code: 0,
30656 current_code: 100,
30657 code_delta: 100,
30658 baseline_comment: 0,
30659 current_comment: 0,
30660 comment_delta: 0,
30661 baseline_blank: 0,
30662 current_blank: 0,
30663 blank_delta: 0,
30664 total_delta: 100,
30665 },
30666 sloc_core::FileDelta {
30667 relative_path: "removed.rs".to_string(),
30668 language: Some("Rust".to_string()),
30669 status: FileChangeStatus::Removed,
30670 baseline_code: 50,
30671 current_code: 0,
30672 code_delta: -50,
30673 baseline_comment: 0,
30674 current_comment: 0,
30675 comment_delta: 0,
30676 baseline_blank: 0,
30677 current_blank: 0,
30678 blank_delta: 0,
30679 total_delta: -50,
30680 },
30681 sloc_core::FileDelta {
30682 relative_path: "modified.rs".to_string(),
30683 language: Some("Rust".to_string()),
30684 status: FileChangeStatus::Modified,
30685 baseline_code: 80,
30686 current_code: 100,
30687 code_delta: 20,
30688 baseline_comment: 0,
30689 current_comment: 0,
30690 comment_delta: 0,
30691 baseline_blank: 0,
30692 current_blank: 0,
30693 blank_delta: 0,
30694 total_delta: 20,
30695 },
30696 sloc_core::FileDelta {
30697 relative_path: "unchanged.rs".to_string(),
30698 language: Some("Rust".to_string()),
30699 status: FileChangeStatus::Unchanged,
30700 baseline_code: 200,
30701 current_code: 200,
30702 code_delta: 0,
30703 baseline_comment: 0,
30704 current_comment: 0,
30705 comment_delta: 0,
30706 baseline_blank: 0,
30707 current_blank: 0,
30708 blank_delta: 0,
30709 total_delta: 0,
30710 },
30711 ],
30712 files_added: 1,
30713 files_removed: 1,
30714 files_modified: 1,
30715 files_unchanged: 1,
30716 }
30717 }
30718
30719 #[test]
30720 fn sum_added_counts_added_and_positive_modified() {
30721 let cmp = make_mixed_scan_comparison();
30722 assert_eq!(sum_added_code_lines(&cmp), 120);
30723 }
30724
30725 #[test]
30726 fn sum_removed_counts_removed_baseline() {
30727 let cmp = make_mixed_scan_comparison();
30728 assert_eq!(sum_removed_code_lines(&cmp), 50);
30729 }
30730
30731 #[test]
30732 fn sum_unmodified_counts_unchanged_files() {
30733 let cmp = make_mixed_scan_comparison();
30734 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
30735 }
30736
30737 #[test]
30740 fn detect_coverage_tool_rust_project() {
30741 let dir = tempfile::tempdir().unwrap();
30742 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
30743 let (tool, cmd) = detect_coverage_tool(dir.path());
30744 assert_eq!(tool, Some("cargo-llvm-cov"));
30745 assert!(cmd.is_some());
30746 }
30747
30748 #[test]
30749 fn detect_coverage_tool_java_gradle() {
30750 let dir = tempfile::tempdir().unwrap();
30751 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
30752 let (tool, _) = detect_coverage_tool(dir.path());
30753 assert_eq!(tool, Some("jacoco"));
30754 }
30755
30756 #[test]
30757 fn detect_coverage_tool_python_pyproject() {
30758 let dir = tempfile::tempdir().unwrap();
30759 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
30760 let (tool, _) = detect_coverage_tool(dir.path());
30761 assert_eq!(tool, Some("pytest-cov"));
30762 }
30763
30764 #[test]
30765 fn detect_coverage_tool_unknown_project() {
30766 let dir = tempfile::tempdir().unwrap();
30767 let (tool, cmd) = detect_coverage_tool(dir.path());
30768 assert!(tool.is_none() && cmd.is_none());
30769 }
30770
30771 #[test]
30774 fn sanitize_path_str_unc_drive_stripped() {
30775 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
30776 }
30777
30778 #[test]
30779 fn sanitize_path_str_unc_network_stripped() {
30780 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
30781 }
30782
30783 #[test]
30784 fn sanitize_path_str_plain_path_unchanged() {
30785 assert_eq!(
30786 sanitize_path_str("/home/user/project"),
30787 "/home/user/project"
30788 );
30789 }
30790
30791 #[test]
30792 fn display_path_plain_linux_unchanged() {
30793 assert_eq!(
30794 display_path(Path::new("/home/user/project")),
30795 "/home/user/project"
30796 );
30797 }
30798
30799 #[test]
30800 fn display_path_unc_drive_stripped() {
30801 let result = display_path(Path::new(r"\\?\C:\Users\user"));
30802 assert_eq!(result, r"C:\Users\user");
30803 }
30804
30805 #[test]
30806 fn display_path_unc_network_stripped() {
30807 let result = display_path(Path::new(r"\\?\UNC\server\share"));
30808 assert_eq!(result, r"\\server\share");
30809 }
30810}
30811
30812#[cfg(test)]
30813mod coverage_boost_unit_tests {
30814 use super::*;
30815 use std::path::{Path, PathBuf};
30816
30817 #[tokio::test]
30821 async fn runtime_security_config_scenarios() {
30822 std::env::remove_var("SLOC_API_KEYS");
30823 std::env::remove_var("SLOC_API_KEY");
30824 std::env::remove_var("SLOC_TLS_CERT");
30825 std::env::remove_var("SLOC_TLS_KEY");
30826 std::env::remove_var("SLOC_TRUST_PROXY");
30827 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30828 let cfg = load_runtime_security_config(false);
30829 assert!(cfg.api_keys.is_empty());
30830 assert!(!cfg.tls_enabled);
30831 assert!(!cfg.trust_proxy);
30832
30833 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
30834 std::env::set_var("SLOC_TRUST_PROXY", "1");
30835 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
30836 std::env::set_var("SLOC_RATE_LIMIT", "250");
30837 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
30838 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
30839 let cfg = load_runtime_security_config(true);
30840 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
30841 assert!(cfg.trust_proxy);
30842 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
30843 std::env::remove_var("SLOC_API_KEYS");
30844 std::env::remove_var("SLOC_TRUST_PROXY");
30845 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30846 std::env::remove_var("SLOC_RATE_LIMIT");
30847 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
30848 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
30849 }
30850
30851 #[test]
30852 fn cors_layer_builds_both_modes() {
30853 let _ = build_cors_layer(true);
30854 let _ = build_cors_layer(false);
30855 }
30856
30857 #[test]
30858 fn primary_lan_ip_callable() {
30859 let _ = primary_lan_ip();
30861 }
30862
30863 #[test]
30864 fn safe_redirect_allows_relative_rejects_absolute() {
30865 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
30866 assert_eq!(safe_redirect("https://evil.example/x"), "/");
30867 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
30868 assert_eq!(default_redirect(), "/view-reports");
30869 }
30870
30871 #[test]
30872 fn tarball_size_caps_env_override() {
30873 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
30874 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
30875 let (c, d) = parse_tarball_size_caps();
30876 assert_eq!(c, 1024 * 1024);
30877 assert_eq!(d, 2 * 1024 * 1024);
30878 std::env::remove_var("SLOC_MAX_TARBALL_MB");
30879 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
30880 let (c2, _) = parse_tarball_size_caps();
30881 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
30882 }
30883
30884 #[test]
30885 fn upload_path_helpers() {
30886 let base = upload_base_dir();
30887 let staged = upload_staging_path("abc123");
30888 assert!(staged.starts_with(&base));
30889 assert!(
30890 is_upload_tmp_path(&staged),
30891 "staging path is an upload tmp path"
30892 );
30893 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
30894 }
30895
30896 #[test]
30897 fn git_clones_dir_env_override() {
30898 std::env::remove_var("SLOC_GIT_CLONES_DIR");
30899 let def = resolve_git_clones_dir(Path::new("/out"));
30900 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
30901 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
30902 assert_eq!(
30903 resolve_git_clones_dir(Path::new("/out")),
30904 PathBuf::from("/custom/clones")
30905 );
30906 std::env::remove_var("SLOC_GIT_CLONES_DIR");
30907 }
30908
30909 #[test]
30910 fn html_report_file_detection() {
30911 let dir = std::env::temp_dir().join("sloc_html_detect");
30912 let _ = std::fs::create_dir_all(&dir);
30913 let good = dir.join("report_x.html");
30914 std::fs::write(&good, "<html></html>").unwrap();
30915 let bad = dir.join("notes.txt");
30916 std::fs::write(&bad, "x").unwrap();
30917 assert!(is_html_report_file(&good));
30918 assert!(!is_html_report_file(&bad));
30919 assert!(find_html_report_in_dir(&dir).is_some());
30920 let _ = std::fs::remove_dir_all(&dir);
30921 }
30922
30923 #[test]
30924 fn multi_delta_class_and_format() {
30925 assert_eq!(multi_delta_class(5), "pos");
30926 assert_eq!(multi_delta_class(-5), "neg");
30927 assert_eq!(multi_delta_class(0), "zero");
30928 assert_eq!(multi_fmt_delta(3), "+3");
30929 assert_eq!(multi_fmt_delta(-3), "-3");
30930 assert_eq!(multi_fmt_delta(0), "0");
30931 }
30932
30933 #[test]
30934 fn git_clone_dest_sanitizes() {
30935 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
30936 assert!(dest.starts_with("/clones"));
30937 let name = dest.file_name().unwrap().to_str().unwrap();
30938 assert!(name
30939 .chars()
30940 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
30941 }
30942}