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
999pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1002 std::env::set_var("SLOC_HEADLESS", "1");
1003 let tmp = std::env::temp_dir().join("sloc_test_auth_lockout");
1004 let state = AppState {
1005 base_config: AppConfig::default(),
1006 artifacts: Arc::new(Mutex::new(HashMap::new())),
1007 async_runs: Arc::new(Mutex::new(HashMap::new())),
1008 registry: Arc::new(Mutex::new(ScanRegistry::default())),
1009 registry_path: tmp.join("registry.json"),
1010 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1011 server_mode: false,
1012 tls_enabled: false,
1013 api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
1014 rate_limiter: Arc::new(IpRateLimiter::new(
1015 Duration::from_mins(1),
1016 600,
1017 2, Duration::from_millis(200), )),
1020 trust_proxy: false,
1021 trusted_proxy_ips: vec![],
1022 git_clones_dir: tmp.join("git-clones"),
1023 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1024 schedules_path: tmp.join("schedules.json"),
1025 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1026 scan_profiles_path: tmp.join("scan_profiles.json"),
1027 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1028 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1029 confluence_path: tmp.join("confluence_config.json"),
1030 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1031 watched_dirs_path: tmp.join("watched_dirs.json"),
1032 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1033 cleanup_policy_path: tmp.join("cleanup_policy.json"),
1034 cleanup_task_handle: Arc::new(Mutex::new(None)),
1035 };
1036 build_router(state)
1037}
1038
1039struct RuntimeSecurityConfig {
1040 api_keys: Vec<secrecy::SecretBox<String>>,
1041 tls_cert: Option<String>,
1042 tls_key: Option<String>,
1043 tls_enabled: bool,
1044 trust_proxy: bool,
1045 trusted_proxy_ips: Vec<IpAddr>,
1046 rate_limiter: Arc<IpRateLimiter>,
1047}
1048
1049fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1050 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1051 .or_else(|_| std::env::var("SLOC_API_KEY"))
1052 .unwrap_or_default()
1053 .split(',')
1054 .map(str::trim)
1055 .filter(|s| !s.is_empty())
1056 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1057 .collect();
1058 if server_mode && api_keys.is_empty() {
1059 println!(
1060 "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
1061 unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
1062 );
1063 }
1064 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1065 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1066 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1067 if server_mode && !tls_enabled {
1068 println!(
1069 "WARNING: TLS is not configured. Traffic is cleartext. \
1070 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1071 or terminate TLS at a reverse proxy (nginx, caddy)."
1072 );
1073 }
1074 if server_mode {
1075 println!(
1076 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1077 to restrict cross-origin access (comma-separated)."
1078 );
1079 }
1080 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1081 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1082 .unwrap_or_default()
1083 .split(',')
1084 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1085 .collect();
1086 if trust_proxy {
1087 if trusted_proxy_ips.is_empty() {
1088 println!(
1089 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1090 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1091 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1092 );
1093 } else {
1094 println!(
1095 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1096 trusted_proxy_ips
1097 .iter()
1098 .map(std::string::ToString::to_string)
1099 .collect::<Vec<_>>()
1100 .join(", ")
1101 );
1102 }
1103 } else if server_mode {
1104 println!(
1105 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1106 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1107 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1108 enable per-client rate limiting via X-Forwarded-For."
1109 );
1110 }
1111 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1112 println!(
1113 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1114 DISABLED for all git operations. Remove this variable before production use."
1115 );
1116 }
1117 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1118 .ok()
1119 .and_then(|v| v.parse::<u32>().ok())
1120 .unwrap_or(10);
1121 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1122 .ok()
1123 .and_then(|v| v.parse::<u64>().ok())
1124 .unwrap_or(3600);
1125 let default_rpm: usize = if server_mode { 120 } else { 600 };
1129 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1130 .ok()
1131 .and_then(|v| v.parse::<usize>().ok())
1132 .unwrap_or(default_rpm);
1133 let rate_limiter = Arc::new(IpRateLimiter::new(
1134 Duration::from_mins(1),
1135 rate_limit_rpm,
1136 auth_lockout_threshold,
1137 Duration::from_secs(auth_lockout_secs),
1138 ));
1139 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1140 RuntimeSecurityConfig {
1141 api_keys,
1142 tls_cert,
1143 tls_key,
1144 tls_enabled,
1145 trust_proxy,
1146 trusted_proxy_ips,
1147 rate_limiter,
1148 }
1149}
1150
1151#[allow(clippy::too_many_lines)]
1160pub async fn serve(config: AppConfig) -> Result<()> {
1161 let bind_address = config.web.bind_address.clone();
1162 let server_mode = config.web.server_mode;
1163 let output_root = resolve_output_root(None);
1164 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1166 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1167 let mut registry = ScanRegistry::load(®istry_path);
1168 registry.prune_stale();
1169 let _ = registry.save(®istry_path);
1170
1171 let sec = load_runtime_security_config(server_mode);
1172 spawn_upload_staging_cleanup();
1173
1174 let git_clones_dir = resolve_git_clones_dir(&output_root);
1175 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1176 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1177 let schedules = ScheduleStore::load(&schedules_path);
1178 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1179 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1180 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1181 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1182 |_| output_root.join("confluence_config.json"),
1183 PathBuf::from,
1184 );
1185 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1186 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1187 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1188 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1189 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1190 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1191 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1192
1193 let state = AppState {
1194 base_config: config,
1195 artifacts: Arc::new(Mutex::new(HashMap::new())),
1196 async_runs: Arc::new(Mutex::new(HashMap::new())),
1197 registry: Arc::new(Mutex::new(registry)),
1198 registry_path,
1199 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1200 server_mode,
1201 tls_enabled: sec.tls_enabled,
1202 api_keys: Arc::new(sec.api_keys),
1203 rate_limiter: sec.rate_limiter,
1204 trust_proxy: sec.trust_proxy,
1205 trusted_proxy_ips: sec.trusted_proxy_ips,
1206 git_clones_dir,
1207 schedules: Arc::new(Mutex::new(schedules)),
1208 schedules_path,
1209 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1210 scan_profiles_path,
1211 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1212 confluence: Arc::new(Mutex::new(confluence)),
1213 confluence_path,
1214 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1215 watched_dirs_path,
1216 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1217 cleanup_policy_path,
1218 cleanup_task_handle: Arc::new(Mutex::new(None)),
1219 };
1220
1221 restart_poll_schedules(&state).await;
1222
1223 {
1225 let enabled = state
1226 .cleanup_policy
1227 .lock()
1228 .await
1229 .policy
1230 .as_ref()
1231 .is_some_and(|p| p.enabled);
1232 if enabled {
1233 let handle = spawn_cleanup_policy_task(state.clone());
1234 *state.cleanup_task_handle.lock().await = Some(handle);
1235 }
1236 }
1237
1238 let app = build_router(state.clone());
1239
1240 let preferred: SocketAddr = bind_address
1245 .parse()
1246 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1247 let (listener, addr) = {
1248 let candidates = (0u16..=9).map(|offset| {
1249 let mut a = preferred;
1250 a.set_port(preferred.port().saturating_add(offset));
1251 a
1252 });
1253 let mut found = None;
1254 for candidate in candidates {
1255 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1256 found = Some((l, candidate));
1257 break;
1258 }
1259 }
1260 found.ok_or_else(|| {
1261 anyhow::anyhow!(
1262 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1263 bind_address,
1264 preferred.port(),
1265 preferred.port().saturating_add(9)
1266 )
1267 })?
1268 };
1269 if addr != preferred {
1270 eprintln!(
1271 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1272 using {} instead.",
1273 preferred.port(),
1274 addr.port()
1275 );
1276 }
1277
1278 if sec.tls_enabled {
1279 let cert_path = sec
1280 .tls_cert
1281 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1282 let key_path = sec
1283 .tls_key
1284 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1285 let tls_config = build_tls_config(&cert_path, &key_path)
1286 .context("failed to load TLS certificate/key")?;
1287 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1288
1289 let url = format!("https://{addr}/");
1290 println!("OxideSLOC server running at {url} (TLS)");
1291 println!("Use Ctrl+C to stop.");
1292
1293 return serve_tls(listener, app, acceptor, server_mode).await;
1294 }
1295
1296 let url = format!("http://{addr}/");
1297 log_startup_url(&url, server_mode);
1298
1299 axum::serve(
1300 listener,
1301 app.into_make_service_with_connect_info::<SocketAddr>(),
1302 )
1303 .with_graceful_shutdown(shutdown_signal(server_mode))
1304 .await
1305 .context("web server terminated unexpectedly")
1306}
1307
1308fn primary_lan_ip() -> Option<String> {
1312 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1313 socket.connect("8.8.8.8:80").ok()?;
1314 let addr = socket.local_addr().ok()?;
1315 let ip = addr.ip();
1316 if ip.is_loopback() {
1317 return None;
1318 }
1319 Some(ip.to_string())
1320}
1321
1322fn log_startup_url(url: &str, server_mode: bool) {
1324 if server_mode {
1325 println!("OxideSLOC server running at {url}");
1326 println!("Use Ctrl+C to stop.");
1327 } else {
1328 println!("OxideSLOC local web UI running at {url}");
1329 println!("Press Ctrl+C to stop the server.");
1330 let open_url = url.to_owned();
1331 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1332 }
1333}
1334
1335fn open_browser_tab(url: &str) {
1337 #[cfg(target_os = "windows")]
1338 let _ = std::process::Command::new("cmd")
1339 .args(["/c", "start", "", url])
1340 .stdout(Stdio::null())
1341 .stderr(Stdio::null())
1342 .spawn();
1343 #[cfg(target_os = "macos")]
1344 let _ = std::process::Command::new("open")
1345 .arg(url)
1346 .stdout(Stdio::null())
1347 .stderr(Stdio::null())
1348 .spawn();
1349 #[cfg(target_os = "linux")]
1350 let _ = std::process::Command::new("xdg-open")
1351 .arg(url)
1352 .stdout(Stdio::null())
1353 .stderr(Stdio::null())
1354 .spawn();
1355}
1356
1357async fn shutdown_signal(server_mode: bool) {
1359 if tokio::signal::ctrl_c().await.is_ok() {
1360 println!();
1361 if server_mode {
1362 println!("Shutting down OxideSLOC server...");
1363 } else {
1364 println!("Shutting down OxideSLOC local web UI...");
1365 }
1366 println!("Server stopped cleanly.");
1367 }
1368}
1369
1370fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1372 use rustls_pki_types::pem::PemObject;
1373 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1374
1375 let cert_bytes =
1376 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1377 let key_bytes =
1378 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1379
1380 let cert_chain: Vec<CertificateDer<'static>> =
1381 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1382 .collect::<std::result::Result<_, _>>()
1383 .context("failed to parse TLS certificates")?;
1384
1385 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1386 .context("failed to parse TLS private key")?;
1387
1388 rustls::ServerConfig::builder()
1389 .with_no_client_auth()
1390 .with_single_cert(cert_chain, key)
1391 .context("failed to build TLS server config")
1392}
1393
1394async fn serve_tls(
1396 listener: tokio::net::TcpListener,
1397 app: Router,
1398 acceptor: tokio_rustls::TlsAcceptor,
1399 server_mode: bool,
1400) -> Result<()> {
1401 use hyper_util::rt::{TokioExecutor, TokioIo};
1402 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1403 use hyper_util::service::TowerToHyperService;
1404 use tower::{Service, ServiceExt};
1405
1406 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1407
1408 loop {
1409 tokio::select! {
1410 biased;
1411 _ = tokio::signal::ctrl_c() => {
1412 println!();
1413 if server_mode {
1414 println!("Shutting down OxideSLOC server...");
1415 } else {
1416 println!("Shutting down OxideSLOC local web UI...");
1417 }
1418 println!("Server stopped cleanly.");
1419 return Ok(());
1420 }
1421 result = listener.accept() => {
1422 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1423 let acceptor = acceptor.clone();
1424 let mut factory = make_svc.clone();
1425
1426 tokio::spawn(async move {
1427 let tls = match acceptor.accept(tcp).await {
1428 Ok(s) => s,
1429 Err(e) => {
1430 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1431 return;
1432 }
1433 };
1434 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1435 Ok(f) => match Service::call(f, peer_addr).await {
1436 Ok(s) => s,
1437 Err(_) => return,
1438 },
1439 Err(_) => return,
1440 };
1441 let io = TokioIo::new(tls);
1442 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1443 .serve_connection(io, TowerToHyperService::new(svc))
1444 .await
1445 {
1446 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1447 }
1448 });
1449 }
1450 }
1451 }
1452}
1453
1454fn build_cors_layer(server_mode: bool) -> CorsLayer {
1457 if server_mode {
1458 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1459 .unwrap_or_default()
1460 .split(',')
1461 .filter(|s| !s.is_empty())
1462 .filter_map(|s| s.trim().parse().ok())
1463 .collect();
1464 if allowed.is_empty() {
1465 return CorsLayer::new();
1466 }
1467 CorsLayer::new()
1468 .allow_origin(AllowOrigin::list(allowed))
1469 .allow_methods(AllowMethods::list([
1470 axum::http::Method::GET,
1471 axum::http::Method::POST,
1472 ]))
1473 .allow_headers(AllowHeaders::list([
1474 axum::http::header::AUTHORIZATION,
1475 axum::http::header::CONTENT_TYPE,
1476 ]))
1477 } else {
1478 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1479 let s = origin.to_str().unwrap_or("");
1480 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1481 }))
1482 }
1483}
1484
1485async fn add_security_headers(
1486 State(state): State<AppState>,
1487 mut req: Request<Body>,
1488 next: Next,
1489) -> Response {
1490 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1491 req.extensions_mut().insert(CspNonce(nonce.clone()));
1492 let mut resp = next.run(req).await;
1493 let h = resp.headers_mut();
1494 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1495 h.insert(
1496 "X-Content-Type-Options",
1497 HeaderValue::from_static("nosniff"),
1498 );
1499 h.insert(
1500 "Referrer-Policy",
1501 HeaderValue::from_static("strict-origin-when-cross-origin"),
1502 );
1503 let csp = format!(
1504 "default-src 'self'; \
1505 style-src 'self' 'unsafe-inline'; \
1506 img-src 'self' data: blob:; \
1507 script-src 'self' 'nonce-{nonce}'; \
1508 font-src 'self' data:; \
1509 object-src 'none'; \
1510 frame-ancestors 'none'"
1511 );
1512 h.insert(
1513 "Content-Security-Policy",
1514 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1515 HeaderValue::from_static(
1516 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1517 )
1518 }),
1519 );
1520 h.insert(
1521 "X-Permitted-Cross-Domain-Policies",
1522 HeaderValue::from_static("none"),
1523 );
1524 h.insert(
1525 "Permissions-Policy",
1526 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1527 );
1528 h.insert(
1529 "Cross-Origin-Opener-Policy",
1530 HeaderValue::from_static("same-origin"),
1531 );
1532 h.insert(
1533 "Cross-Origin-Resource-Policy",
1534 HeaderValue::from_static("same-origin"),
1535 );
1536 if state.tls_enabled {
1537 h.insert(
1538 "Strict-Transport-Security",
1539 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1540 );
1541 }
1542 resp
1543}
1544
1545async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1546 let peer_ip = req
1547 .extensions()
1548 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1549 .map(|c| c.0.ip());
1550
1551 let ip = peer_ip
1555 .and_then(|peer| {
1556 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1557 req.headers()
1558 .get("X-Forwarded-For")
1559 .and_then(|v| v.to_str().ok())
1560 .and_then(|s| s.split(',').next())
1561 .and_then(|s| s.trim().parse::<IpAddr>().ok())
1562 } else {
1563 None
1564 }
1565 })
1566 .or(peer_ip)
1567 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1568
1569 if !state.rate_limiter.is_allowed(ip) {
1570 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1571 path = %req.uri().path(), "Rate limit exceeded");
1572 return (
1573 StatusCode::TOO_MANY_REQUESTS,
1574 [(header::RETRY_AFTER, "60")],
1575 "429 Too Many Requests\n",
1576 )
1577 .into_response();
1578 }
1579 next.run(req).await
1580}
1581
1582async fn splash(
1583 State(state): State<AppState>,
1584 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1585) -> impl IntoResponse {
1586 let lan_ip = if state.server_mode {
1587 primary_lan_ip()
1588 } else {
1589 None
1590 };
1591 let port = state
1592 .base_config
1593 .web
1594 .bind_address
1595 .rsplit(':')
1596 .next()
1597 .and_then(|p| p.parse::<u16>().ok())
1598 .unwrap_or(4317);
1599 let has_api_key = !state.api_keys.is_empty();
1600 let template = SplashTemplate {
1601 csp_nonce,
1602 server_mode: state.server_mode,
1603 lan_ip,
1604 port,
1605 version: env!("CARGO_PKG_VERSION"),
1606 has_api_key,
1607 };
1608 Html(
1609 template
1610 .render()
1611 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1612 )
1613}
1614
1615async fn index(
1616 State(state): State<AppState>,
1617 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1618 Query(query): Query<IndexQuery>,
1619) -> impl IntoResponse {
1620 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1621 let policy = query
1622 .mixed_line_policy
1623 .unwrap_or_else(|| "code_only".to_string());
1624 let behavior = query
1625 .binary_file_behavior
1626 .unwrap_or_else(|| "skip".to_string());
1627 let cfg = ScanConfig {
1628 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1629 path: query.path.unwrap_or_default(),
1630 include_globs: query.include_globs.unwrap_or_default(),
1631 exclude_globs: query.exclude_globs.unwrap_or_default(),
1632 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1633 mixed_line_policy: policy,
1634 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1635 != Some("off"),
1636 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1637 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1638 vendor_directory_detection: query.vendor_directory_detection.as_deref()
1639 != Some("disabled"),
1640 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1641 binary_file_behavior: behavior,
1642 output_dir: query.output_dir.unwrap_or_default(),
1643 report_title: query.report_title.unwrap_or_default(),
1644 };
1645 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1646 } else {
1647 "{}".to_string()
1648 };
1649
1650 let git_repo = query.git_repo.unwrap_or_default();
1651 let git_ref = query.git_ref.unwrap_or_default();
1652
1653 let git_label = make_git_label(&git_repo, &git_ref);
1654 let git_output_dir = if git_label.is_empty() {
1655 String::new()
1656 } else {
1657 desktop_dir().join(&git_label).display().to_string()
1658 };
1659 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1660 let git_output_dir_json =
1661 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1662
1663 let template = IndexTemplate {
1664 version: env!("CARGO_PKG_VERSION"),
1665 prefill_json,
1666 csp_nonce,
1667 git_repo,
1668 git_ref,
1669 git_label_json,
1670 git_output_dir_json,
1671 server_mode: state.server_mode,
1672 };
1673
1674 Html(
1675 template
1676 .render()
1677 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1678 )
1679}
1680
1681async fn scan_setup_handler(
1682 State(state): State<AppState>,
1683 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1684) -> impl IntoResponse {
1685 let recent_scans_json = {
1686 let arr: Vec<serde_json::Value> = {
1687 let reg = state.registry.lock().await;
1688 reg.entries
1689 .iter()
1690 .rev()
1691 .take(6)
1692 .map(|e| {
1693 let run_dir = e
1694 .html_path
1695 .as_ref()
1696 .or(e.json_path.as_ref())
1697 .and_then(|p| p.parent().map(PathBuf::from));
1698 let config_val: Option<serde_json::Value> = run_dir
1699 .and_then(|d| find_scan_config_in_dir(&d))
1700 .and_then(|p| fs::read_to_string(&p).ok())
1701 .and_then(|s| serde_json::from_str(&s).ok());
1702 serde_json::json!({
1703 "project_label": e.project_label,
1704 "timestamp": fmt_la_time(e.timestamp_utc),
1705 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1706 "config": config_val,
1707 })
1708 })
1709 .collect()
1710 };
1711 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1712 };
1713
1714 let template = ScanSetupTemplate {
1715 version: env!("CARGO_PKG_VERSION"),
1716 recent_scans_json,
1717 csp_nonce,
1718 };
1719 Html(
1720 template
1721 .render()
1722 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1723 )
1724}
1725
1726async fn healthz() -> &'static str {
1727 "ok"
1728}
1729
1730async fn api_version_handler() -> impl IntoResponse {
1731 axum::Json(serde_json::json!({
1732 "name": "oxide-sloc",
1733 "version": env!("CARGO_PKG_VERSION"),
1734 }))
1735}
1736
1737fn prom_runs_total() -> &'static prometheus::IntCounter {
1740 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
1741 COUNTER.get_or_init(|| {
1742 prometheus::register_int_counter!(
1743 "oxide_sloc_runs_total",
1744 "Total number of completed analysis runs"
1745 )
1746 .expect("failed to register oxide_sloc_runs_total counter")
1747 })
1748}
1749
1750async fn metrics_handler() -> impl IntoResponse {
1751 use prometheus::Encoder as _;
1752 let mut buf = Vec::new();
1753 let encoder = prometheus::TextEncoder::new();
1754 let _ = encoder.encode(&prometheus::gather(), &mut buf);
1755 (
1756 [(
1757 axum::http::header::CONTENT_TYPE,
1758 "text/plain; version=0.0.4; charset=utf-8",
1759 )],
1760 buf,
1761 )
1762}
1763
1764static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
1765
1766async fn openapi_yaml_handler() -> impl IntoResponse {
1767 (
1768 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
1769 OPENAPI_YAML,
1770 )
1771}
1772
1773static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
1774static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
1775
1776async fn llms_txt_handler() -> impl IntoResponse {
1777 (
1778 [
1779 (
1780 axum::http::header::CONTENT_TYPE,
1781 "text/plain; charset=utf-8",
1782 ),
1783 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1784 ],
1785 LLMS_TXT,
1786 )
1787}
1788
1789async fn llms_full_txt_handler() -> impl IntoResponse {
1790 (
1791 [
1792 (
1793 axum::http::header::CONTENT_TYPE,
1794 "text/plain; charset=utf-8",
1795 ),
1796 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
1797 ],
1798 LLMS_FULL_TXT,
1799 )
1800}
1801
1802async fn api_docs_handler(
1803 State(state): State<AppState>,
1804 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1805) -> impl IntoResponse {
1806 let has_api_key = !state.api_keys.is_empty();
1807 Html(
1808 ApiDocsTemplate {
1809 has_api_key,
1810 csp_nonce,
1811 version: env!("CARGO_PKG_VERSION"),
1812 }
1813 .render()
1814 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1815 )
1816}
1817
1818async fn chart_js_handler() -> impl IntoResponse {
1819 (
1820 [
1821 (
1822 header::CONTENT_TYPE,
1823 "application/javascript; charset=utf-8",
1824 ),
1825 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1826 ],
1827 CHART_JS,
1828 )
1829}
1830
1831async fn report_chart_js_handler() -> impl IntoResponse {
1832 (
1833 [
1834 (
1835 header::CONTENT_TYPE,
1836 "application/javascript; charset=utf-8",
1837 ),
1838 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
1839 ],
1840 REPORT_CHART_JS,
1841 )
1842}
1843
1844#[derive(Debug, Deserialize)]
1845struct AnalyzeForm {
1846 path: String,
1847 git_repo: Option<String>,
1848 git_ref: Option<String>,
1849 mixed_line_policy: Option<MixedLinePolicy>,
1850 python_docstrings_as_comments: Option<String>,
1851 generated_file_detection: Option<String>,
1852 minified_file_detection: Option<String>,
1853 vendor_directory_detection: Option<String>,
1854 include_lockfiles: Option<String>,
1855 binary_file_behavior: Option<BinaryFileBehavior>,
1856 output_dir: Option<String>,
1857 report_title: Option<String>,
1858 report_header_footer: Option<String>,
1859 include_globs: Option<String>,
1860 exclude_globs: Option<String>,
1861 submodule_breakdown: Option<String>,
1862 coverage_file: Option<String>,
1863 continuation_line_policy: Option<ContinuationLinePolicy>,
1864 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
1865 count_compiler_directives: Option<String>,
1866 style_col_threshold: Option<String>,
1867 style_analysis_enabled: Option<String>,
1868 style_score_threshold: Option<String>,
1869 style_lang_scope: Option<String>,
1870 cocomo_mode: Option<String>,
1872 complexity_alert: Option<String>,
1874 exclude_duplicates: Option<String>,
1876}
1877
1878#[allow(clippy::struct_excessive_bools)]
1879#[derive(Debug, Serialize, Deserialize, Clone)]
1880struct ScanConfig {
1881 oxide_sloc_version: String,
1882 path: String,
1883 include_globs: String,
1884 exclude_globs: String,
1885 submodule_breakdown: bool,
1886 mixed_line_policy: String,
1887 python_docstrings_as_comments: bool,
1888 generated_file_detection: bool,
1889 minified_file_detection: bool,
1890 vendor_directory_detection: bool,
1891 include_lockfiles: bool,
1892 binary_file_behavior: String,
1893 output_dir: String,
1894 report_title: String,
1895}
1896
1897#[derive(Debug, Deserialize, Default)]
1898struct IndexQuery {
1899 path: Option<String>,
1900 include_globs: Option<String>,
1901 exclude_globs: Option<String>,
1902 submodule_breakdown: Option<String>,
1903 mixed_line_policy: Option<String>,
1904 python_docstrings_as_comments: Option<String>,
1905 generated_file_detection: Option<String>,
1906 minified_file_detection: Option<String>,
1907 vendor_directory_detection: Option<String>,
1908 include_lockfiles: Option<String>,
1909 binary_file_behavior: Option<String>,
1910 output_dir: Option<String>,
1911 report_title: Option<String>,
1912 prefilled: Option<String>,
1913 git_repo: Option<String>,
1914 git_ref: Option<String>,
1915}
1916
1917#[derive(Debug, Deserialize)]
1918struct PreviewQuery {
1919 path: Option<String>,
1920 include_globs: Option<String>,
1921 exclude_globs: Option<String>,
1922}
1923
1924#[cfg(feature = "native-dialog")]
1925#[derive(Debug, Deserialize)]
1926struct PickDirectoryQuery {
1927 kind: Option<String>,
1928 current: Option<String>,
1929}
1930
1931#[cfg(not(feature = "native-dialog"))]
1932#[derive(Debug, Deserialize)]
1933struct PickDirectoryQuery {}
1934
1935#[derive(Debug, Deserialize, Default)]
1936struct ArtifactQuery {
1937 download: Option<String>,
1938}
1939
1940#[cfg(feature = "native-dialog")]
1941#[derive(Debug, Serialize)]
1942struct PickDirectoryResponse {
1943 selected_path: Option<String>,
1944 cancelled: bool,
1945}
1946
1947#[cfg(feature = "native-dialog")]
1948async fn pick_directory_handler(
1949 State(state): State<AppState>,
1950 Query(query): Query<PickDirectoryQuery>,
1951) -> Response {
1952 if state.server_mode {
1953 return StatusCode::NOT_FOUND.into_response();
1954 }
1955 if std::env::var("SLOC_HEADLESS").is_ok() {
1957 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
1958 .into_response();
1959 }
1960
1961 let is_coverage = query.kind.as_deref() == Some("coverage");
1962 let title = match query.kind.as_deref() {
1963 Some("output") => "Select output directory",
1964 Some("reports") => "Select folder containing saved reports",
1965 Some("coverage") => "Select LCOV coverage file",
1966 _ => "Select project directory",
1967 }
1968 .to_owned();
1969 let current = query.current.clone();
1970
1971 let picked = tokio::task::spawn_blocking(move || {
1972 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1975 let fg_tid = win_dialog_focus::attach_to_foreground();
1976 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1977 win_dialog_focus::flash_dialog_when_ready(title.clone());
1978
1979 let mut dialog = rfd::FileDialog::new().set_title(&title);
1980 if let Some(current) = current.as_deref() {
1981 let resolved = resolve_input_path(current);
1982 let seed = if resolved.is_dir() {
1983 Some(resolved)
1984 } else {
1985 resolved.parent().map(Path::to_path_buf)
1986 };
1987 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1988 dialog = dialog.set_directory(seed_dir);
1989 }
1990 }
1991 let result = if is_coverage {
1992 dialog
1993 .add_filter(
1994 "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1995 &["info", "lcov", "xml"],
1996 )
1997 .pick_file()
1998 } else {
1999 dialog.pick_folder()
2000 };
2001
2002 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2003 win_dialog_focus::detach_from_foreground(fg_tid);
2004
2005 result
2006 })
2007 .await
2008 .unwrap_or(None);
2009
2010 Json(PickDirectoryResponse {
2011 selected_path: picked.as_ref().map(|p| display_path(p)),
2012 cancelled: picked.is_none(),
2013 })
2014 .into_response()
2015}
2016
2017#[cfg(not(feature = "native-dialog"))]
2018async fn pick_directory_handler(
2019 State(_state): State<AppState>,
2020 Query(_query): Query<PickDirectoryQuery>,
2021) -> Response {
2022 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2023}
2024
2025#[cfg(feature = "native-dialog")]
2026async fn pick_file_handler(State(state): State<AppState>) -> Response {
2027 if state.server_mode {
2028 return StatusCode::NOT_FOUND.into_response();
2029 }
2030 if std::env::var("SLOC_HEADLESS").is_ok() {
2031 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2032 .into_response();
2033 }
2034 let picked = tokio::task::spawn_blocking(|| {
2035 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2036 let fg_tid = win_dialog_focus::attach_to_foreground();
2037 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2038 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2039
2040 let result = rfd::FileDialog::new()
2041 .set_title("Select HTML report")
2042 .add_filter("HTML report", &["html"])
2043 .pick_file();
2044
2045 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2046 win_dialog_focus::detach_from_foreground(fg_tid);
2047
2048 result
2049 })
2050 .await
2051 .unwrap_or(None);
2052 Json(PickDirectoryResponse {
2053 selected_path: picked.as_ref().map(|p| display_path(p)),
2054 cancelled: picked.is_none(),
2055 })
2056 .into_response()
2057}
2058
2059#[cfg(not(feature = "native-dialog"))]
2060async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2061 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2062}
2063
2064fn is_upload_tmp_path(path: &Path) -> bool {
2069 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2070 path.starts_with(&upload_root)
2071}
2072
2073fn is_sample_path(path: &Path) -> bool {
2076 let root = workspace_root();
2077 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2078}
2079
2080fn upload_base_dir() -> PathBuf {
2082 std::env::temp_dir().join("oxide-sloc-uploads")
2083}
2084
2085fn upload_staging_path(id: &str) -> PathBuf {
2087 upload_base_dir().join(id)
2088}
2089
2090#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2094 const MAX_FILES: usize = 50_000;
2095 if body.files.is_empty() {
2096 return Err((
2097 StatusCode::BAD_REQUEST,
2098 Json(serde_json::json!({"error": "No files received"})),
2099 )
2100 .into_response());
2101 }
2102 if body.files.len() > MAX_FILES {
2103 return Err((
2104 StatusCode::PAYLOAD_TOO_LARGE,
2105 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2106 )
2107 .into_response());
2108 }
2109 Ok(())
2110}
2111
2112fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2115 match id {
2116 Some(id)
2117 if !id.is_empty()
2118 && id.len() <= 36
2119 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2120 {
2121 (id.to_string(), upload_staging_path(id))
2122 }
2123 _ => {
2124 let new_id = uuid::Uuid::new_v4().to_string();
2125 let staging = upload_staging_path(&new_id);
2126 (new_id, staging)
2127 }
2128 }
2129}
2130
2131#[allow(clippy::result_large_err)]
2136async fn stage_decoded_entry(
2137 entry: &UploadedFile,
2138 staging: &Path,
2139 total_bytes: &mut usize,
2140 project_root: &mut Option<PathBuf>,
2141) -> Result<(), Response> {
2142 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2143
2144 let Ok(data) = base64::Engine::decode(
2145 &base64::engine::general_purpose::STANDARD,
2146 entry.content.as_bytes(),
2147 ) else {
2148 return Ok(());
2149 };
2150
2151 *total_bytes += data.len();
2152 if *total_bytes > MAX_TOTAL_BYTES {
2153 return Err((
2154 StatusCode::PAYLOAD_TOO_LARGE,
2155 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2156 )
2157 .into_response());
2158 }
2159
2160 let rel = std::path::Path::new(&entry.path);
2161 if project_root.is_none() {
2162 if let Some(first) = rel.components().next() {
2163 *project_root = Some(staging.join(first.as_os_str()));
2164 }
2165 }
2166
2167 let dest = staging.join(rel);
2168 if let Some(parent) = dest.parent() {
2169 if tokio::fs::create_dir_all(parent).await.is_err() {
2170 return Err((
2171 StatusCode::INTERNAL_SERVER_ERROR,
2172 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2173 )
2174 .into_response());
2175 }
2176 }
2177
2178 if tokio::fs::write(&dest, &data).await.is_err() {
2179 return Err((
2180 StatusCode::INTERNAL_SERVER_ERROR,
2181 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2182 )
2183 .into_response());
2184 }
2185
2186 Ok(())
2187}
2188
2189async fn write_upload_files(
2193 files: &[UploadedFile],
2194 staging: &Path,
2195 upload_id: &str,
2196) -> Result<(usize, Option<PathBuf>), Response> {
2197 let mut total_bytes: usize = 0;
2198 let mut project_root: Option<PathBuf> = None;
2199 let mut traversal_attempts: usize = 0;
2200
2201 for entry in files {
2202 let rel = std::path::Path::new(&entry.path);
2203 if rel
2204 .components()
2205 .any(|c| matches!(c, std::path::Component::ParentDir))
2206 {
2207 traversal_attempts += 1;
2208 if traversal_attempts >= 5 {
2209 let _ = tokio::fs::remove_dir_all(staging).await;
2210 tracing::warn!(
2211 event = "upload_path_traversal",
2212 upload_id = %upload_id,
2213 "Upload rejected: repeated path traversal attempts detected"
2214 );
2215 return Err((
2216 StatusCode::BAD_REQUEST,
2217 Json(serde_json::json!({"error": "Upload rejected"})),
2218 )
2219 .into_response());
2220 }
2221 continue;
2222 }
2223
2224 if let Err(resp) =
2225 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2226 {
2227 let _ = tokio::fs::remove_dir_all(staging).await;
2228 return Err(resp);
2229 }
2230 }
2231
2232 Ok((files.len(), project_root))
2233}
2234
2235fn parse_tarball_size_caps() -> (u64, u64) {
2238 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2239 .ok()
2240 .and_then(|v| v.parse().ok())
2241 .unwrap_or(2048_u64)
2242 * 1024
2243 * 1024;
2244 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2245 .ok()
2246 .and_then(|v| v.parse().ok())
2247 .unwrap_or(10_240_u64)
2248 * 1024
2249 * 1024;
2250 (compressed, decompressed)
2251}
2252
2253#[allow(clippy::result_large_err)] async fn stream_body_to_file(
2258 body: axum::body::Body,
2259 dest_path: &Path,
2260 max_bytes: u64,
2261) -> Result<u64, Response> {
2262 use http_body_util::BodyExt as _;
2263 use tokio::io::AsyncWriteExt as _;
2264
2265 let mut file = match tokio::fs::File::create(dest_path).await {
2266 Ok(f) => f,
2267 Err(e) => {
2268 tracing::error!(
2269 event = "upload_io_error",
2270 "failed to create tarball temp file: {e}"
2271 );
2272 return Err((
2273 StatusCode::INTERNAL_SERVER_ERROR,
2274 Json(serde_json::json!({"error": "Upload initialization failed"})),
2275 )
2276 .into_response());
2277 }
2278 };
2279
2280 let mut body = body;
2281 let mut written: u64 = 0;
2282 loop {
2283 match body.frame().await {
2284 None => break,
2285 Some(Err(e)) => {
2286 let _ = tokio::fs::remove_file(dest_path).await;
2287 return Err((
2288 StatusCode::BAD_REQUEST,
2289 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2290 )
2291 .into_response());
2292 }
2293 Some(Ok(frame)) => {
2294 if let Ok(data) = frame.into_data() {
2295 written += data.len() as u64;
2296 if written > max_bytes {
2297 let _ = tokio::fs::remove_file(dest_path).await;
2298 return Err((
2299 StatusCode::PAYLOAD_TOO_LARGE,
2300 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2301 )
2302 .into_response());
2303 }
2304 if let Err(e) = file.write_all(&data).await {
2305 let _ = tokio::fs::remove_file(dest_path).await;
2306 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2307 return Err((
2308 StatusCode::INTERNAL_SERVER_ERROR,
2309 Json(serde_json::json!({"error": "Upload write failed"})),
2310 )
2311 .into_response());
2312 }
2313 }
2314 }
2315 }
2316 }
2317 drop(file);
2318 Ok(written)
2319}
2320
2321#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
2326 tarball_path: &Path,
2327 staging: &Path,
2328 max_decompressed_bytes: u64,
2329) -> Result<(), Response> {
2330 let staging_clone = staging.to_path_buf();
2331 let tarball_clone = tarball_path.to_path_buf();
2332 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2333 let file = std::fs::File::open(&tarball_clone)?;
2334 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2335 let limited = SizeLimitReader {
2336 inner: gz,
2337 remaining: max_decompressed_bytes,
2338 };
2339 let mut archive = tar::Archive::new(limited);
2340 archive.set_overwrite(true);
2341 archive.set_preserve_permissions(false);
2342 std::fs::create_dir_all(&staging_clone)?;
2343 archive.unpack(&staging_clone)?;
2344 Ok(())
2345 })
2346 .await;
2347 let _ = tokio::fs::remove_file(tarball_path).await;
2348
2349 match extract_result {
2350 Ok(Ok(())) => Ok(()),
2351 Ok(Err(e)) => {
2352 let _ = tokio::fs::remove_dir_all(staging).await;
2353 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2354 tracing::warn!(
2355 event = "upload_extract_error",
2356 "tarball extraction failed: {e:#}"
2357 );
2358 let (status, msg) = if is_size_limit {
2359 (
2360 StatusCode::PAYLOAD_TOO_LARGE,
2361 "Archive exceeds the decompressed size limit",
2362 )
2363 } else {
2364 (StatusCode::BAD_REQUEST, "Failed to extract archive")
2365 };
2366 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2367 }
2368 Err(e) => {
2369 let _ = tokio::fs::remove_dir_all(staging).await;
2370 tracing::error!(
2371 event = "upload_extract_panic",
2372 "tarball extraction task panicked: {e}"
2373 );
2374 Err((
2375 StatusCode::INTERNAL_SERVER_ERROR,
2376 Json(serde_json::json!({"error": "Archive extraction failed"})),
2377 )
2378 .into_response())
2379 }
2380 }
2381}
2382
2383async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2387 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2388 let first = entries.next_entry().await.ok()??;
2389 if !first.path().is_dir() {
2390 return None;
2391 }
2392 if entries.next_entry().await.unwrap_or(None).is_some() {
2393 return None;
2394 }
2395 Some(first.path())
2396}
2397
2398#[derive(Deserialize)]
2405struct UploadDirRequest {
2406 files: Vec<UploadedFile>,
2407 upload_id: Option<String>,
2410}
2411
2412#[derive(Deserialize)]
2413struct UploadedFile {
2414 path: String,
2416 content: String,
2418}
2419
2420async fn upload_directory_handler(
2430 State(state): State<AppState>,
2431 Json(body): Json<UploadDirRequest>,
2432) -> Response {
2433 if !state.server_mode {
2434 return StatusCode::NOT_FOUND.into_response();
2435 }
2436 if let Err(resp) = validate_upload_dir_request(&body) {
2437 return resp;
2438 }
2439 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2442 match write_upload_files(&body.files, &staging, &upload_id).await {
2443 Ok((file_count, project_root)) => {
2444 let scan_root = project_root.unwrap_or_else(|| staging.clone());
2445 Json(serde_json::json!({
2446 "tmp_path": scan_root.to_string_lossy(),
2447 "file_count": file_count,
2448 "upload_id": upload_id.clone()
2449 }))
2450 .into_response()
2451 }
2452 Err(resp) => resp,
2453 }
2454}
2455
2456#[derive(Deserialize)]
2458struct UploadFileRequest {
2459 filename: String,
2461 content: String,
2463}
2464
2465async fn upload_file_handler(
2471 State(state): State<AppState>,
2472 Json(body): Json<UploadFileRequest>,
2473) -> Response {
2474 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
2477 return StatusCode::NOT_FOUND.into_response();
2478 }
2479
2480 let Ok(data) = base64::Engine::decode(
2481 &base64::engine::general_purpose::STANDARD,
2482 body.content.as_bytes(),
2483 ) else {
2484 return (
2485 StatusCode::BAD_REQUEST,
2486 Json(serde_json::json!({"error": "Invalid base64 content"})),
2487 )
2488 .into_response();
2489 };
2490
2491 if data.len() > MAX_FILE_BYTES {
2492 return (
2493 StatusCode::PAYLOAD_TOO_LARGE,
2494 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2495 )
2496 .into_response();
2497 }
2498
2499 let filename = std::path::Path::new(&body.filename)
2501 .file_name()
2502 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2503
2504 let upload_id = uuid::Uuid::new_v4();
2505 let staging = std::env::temp_dir()
2506 .join("oxide-sloc-uploads")
2507 .join(upload_id.to_string());
2508
2509 if tokio::fs::create_dir_all(&staging).await.is_err() {
2510 return (
2511 StatusCode::INTERNAL_SERVER_ERROR,
2512 Json(serde_json::json!({"error": "Failed to create staging directory"})),
2513 )
2514 .into_response();
2515 }
2516
2517 let dest = staging.join(&filename);
2518 if tokio::fs::write(&dest, &data).await.is_err() {
2519 let _ = tokio::fs::remove_dir_all(&staging).await;
2520 return (
2521 StatusCode::INTERNAL_SERVER_ERROR,
2522 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2523 )
2524 .into_response();
2525 }
2526
2527 Json(serde_json::json!({
2528 "tmp_path": dest.to_string_lossy(),
2529 "upload_id": upload_id.to_string()
2530 }))
2531 .into_response()
2532}
2533
2534struct SizeLimitReader<R> {
2549 inner: R,
2550 remaining: u64,
2551}
2552impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2553 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2554 if self.remaining == 0 {
2555 return Err(std::io::Error::other("decompressed size limit exceeded"));
2556 }
2557 let n = self.inner.read(buf)?;
2558 self.remaining = self.remaining.saturating_sub(n as u64);
2559 Ok(n)
2560 }
2561}
2562
2563async fn upload_tarball_handler(
2564 State(state): State<AppState>,
2565 request: axum::extract::Request,
2566) -> Response {
2567 if !state.server_mode {
2568 return StatusCode::NOT_FOUND.into_response();
2569 }
2570
2571 let upload_id = uuid::Uuid::new_v4().to_string();
2572 let upload_base = upload_base_dir();
2573 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2574 let staging = upload_staging_path(&upload_id);
2575 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2576
2577 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2578 tracing::error!(
2579 event = "upload_io_error",
2580 "failed to create upload base dir: {e}"
2581 );
2582 return (
2583 StatusCode::INTERNAL_SERVER_ERROR,
2584 Json(serde_json::json!({"error": "Upload initialization failed"})),
2585 )
2586 .into_response();
2587 }
2588
2589 let compressed_bytes =
2591 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2592 Ok(n) => n,
2593 Err(resp) => return resp,
2594 };
2595
2596 if let Err(resp) =
2598 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2599 {
2600 return resp;
2601 }
2602
2603 let scan_root = find_single_top_dir(&staging)
2608 .await
2609 .unwrap_or_else(|| staging.clone());
2610
2611 let original_bytes = tokio::task::spawn_blocking({
2613 let p = scan_root.clone();
2614 move || dir_size_bytes(&p)
2615 })
2616 .await
2617 .unwrap_or(0);
2618
2619 Json(serde_json::json!({
2620 "tmp_path": scan_root.to_string_lossy(),
2621 "upload_id": upload_id,
2622 "compressed_bytes": compressed_bytes,
2623 "original_bytes": original_bytes,
2624 }))
2625 .into_response()
2626}
2627
2628#[derive(Deserialize)]
2629struct LocateReportForm {
2630 file_path: String,
2631 #[serde(default)]
2632 redirect_url: Option<String>,
2633 #[serde(default)]
2634 expected_run_id: Option<String>,
2635}
2636
2637fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
2639 let html = ErrorTemplate {
2640 message: message.into(),
2641 last_report_url: Some("/view-reports".to_string()),
2642 last_report_label: Some("View Reports".to_string()),
2643 run_id: None,
2644 error_code: None,
2645 csp_nonce: csp_nonce.to_owned(),
2646 version: env!("CARGO_PKG_VERSION"),
2647 }
2648 .render()
2649 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
2650 Html(html).into_response()
2651}
2652
2653fn registry_entry_from_run(
2655 run: &AnalysisRun,
2656 json_path: PathBuf,
2657 html_path: PathBuf,
2658) -> RegistryEntry {
2659 let project_label = run.input_roots.first().map_or_else(
2660 || "Unknown Project".to_string(),
2661 |r| sanitize_project_label(r),
2662 );
2663 RegistryEntry {
2664 run_id: run.tool.run_id.clone(),
2665 timestamp_utc: run.tool.timestamp_utc,
2666 project_label,
2667 input_roots: run.input_roots.clone(),
2668 json_path: Some(json_path),
2669 html_path: Some(html_path),
2670 pdf_path: None,
2671 summary: ScanSummarySnapshot {
2672 files_analyzed: run.summary_totals.files_analyzed,
2673 files_skipped: run.summary_totals.files_skipped,
2674 total_physical_lines: run.summary_totals.total_physical_lines,
2675 code_lines: run.summary_totals.code_lines,
2676 comment_lines: run.summary_totals.comment_lines,
2677 blank_lines: run.summary_totals.blank_lines,
2678 functions: run.summary_totals.functions,
2679 classes: run.summary_totals.classes,
2680 variables: run.summary_totals.variables,
2681 imports: run.summary_totals.imports,
2682 test_count: run.summary_totals.test_count,
2683 coverage_lines_found: run.summary_totals.coverage_lines_found,
2684 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
2685 coverage_functions_found: run.summary_totals.coverage_functions_found,
2686 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
2687 coverage_branches_found: run.summary_totals.coverage_branches_found,
2688 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
2689 },
2690 csv_path: None,
2691 xlsx_path: None,
2692 git_branch: None,
2693 git_commit: None,
2694 git_author: None,
2695 git_tags: None,
2696 git_nearest_tag: None,
2697 git_commit_date: None,
2698 }
2699}
2700
2701pub(crate) async fn register_artifacts_in_registry(
2704 state: &AppState,
2705 label: &str,
2706 run: &AnalysisRun,
2707 artifacts: &RunArtifacts,
2708) {
2709 let Some(json_path) = artifacts.json_path.clone() else {
2710 return;
2711 };
2712 let Some(html_path) = artifacts.html_path.clone() else {
2713 return;
2714 };
2715 let mut entry = registry_entry_from_run(run, json_path, html_path);
2716 entry.project_label = label.to_owned();
2717 let mut reg = state.registry.lock().await;
2718 reg.add_entry(entry);
2719 let _ = reg.save(&state.registry_path);
2720}
2721
2722fn is_html_report_file(p: &Path) -> bool {
2723 p.is_file()
2724 && p.extension()
2725 .and_then(|x| x.to_str())
2726 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
2727 && p.file_name()
2728 .and_then(|n| n.to_str())
2729 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
2730}
2731
2732fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
2733 fs::read_dir(dir)
2734 .ok()?
2735 .flatten()
2736 .map(|e| e.path())
2737 .find(|p| is_html_report_file(p))
2738}
2739
2740fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
2741 if let Some(f) = find_html_report_in_dir(dir) {
2742 return Some(f);
2743 }
2744 if let Ok(rd) = fs::read_dir(dir) {
2745 for entry in rd.flatten() {
2746 let sub = entry.path();
2747 if sub.is_dir() {
2748 if let Some(f) = find_html_report_in_dir(&sub) {
2749 return Some(f);
2750 }
2751 }
2752 }
2753 }
2754 None
2755}
2756
2757#[allow(clippy::result_large_err)]
2762fn validate_locate_request(
2763 state: &AppState,
2764 file_path: &str,
2765 csp_nonce: &str,
2766) -> Result<(PathBuf, PathBuf), Response> {
2767 let raw = PathBuf::from(file_path);
2768
2769 let html_path = if raw.is_dir() {
2771 let found = find_html_report_in_tree(&raw);
2772 match found {
2773 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
2774 None => {
2775 return Err(locate_report_error(
2776 "No HTML report file found in the selected folder.\n\nMake sure you selected \
2777 the folder that contains your scan output (result_*.html or report_*.html).",
2778 csp_nonce,
2779 ));
2780 }
2781 }
2782 } else {
2783 let file_ext = raw
2784 .extension()
2785 .and_then(|e| e.to_str())
2786 .unwrap_or("")
2787 .to_ascii_lowercase();
2788 if file_ext != "html" {
2789 return Err(locate_report_error(
2790 "Please select the scan output folder, or an .html report file directly.",
2791 csp_nonce,
2792 ));
2793 }
2794 match fs::canonicalize(&raw) {
2795 Ok(p) => strip_unc_prefix(p),
2796 Err(_) => {
2797 return Err(locate_report_error(
2798 "Report file not found or path is invalid.",
2799 csp_nonce,
2800 ));
2801 }
2802 }
2803 };
2804
2805 if state.server_mode {
2806 let output_root = resolve_output_root(None);
2807 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
2808 if !html_path.starts_with(&canonical_root) {
2809 return Err(locate_report_error(
2810 "Report file must be within the configured output directory.",
2811 csp_nonce,
2812 ));
2813 }
2814 }
2815 let parent = match html_path.parent() {
2816 Some(p) => p.to_path_buf(),
2817 None => {
2818 return Err(locate_report_error(
2819 "Report file has no parent directory.",
2820 csp_nonce,
2821 ));
2822 }
2823 };
2824 Ok((html_path, parent))
2825}
2826
2827fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
2829 if want_json {
2830 (
2831 StatusCode::UNPROCESSABLE_ENTITY,
2832 axum::Json(serde_json::json!({"ok": false, "message": msg})),
2833 )
2834 .into_response()
2835 } else {
2836 locate_report_error(msg, csp_nonce)
2837 }
2838}
2839
2840fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
2842 if want_json {
2843 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
2844 } else {
2845 axum::response::Redirect::to(redirect).into_response()
2846 }
2847}
2848
2849fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
2852 for jpath in candidates {
2853 if let Ok(run) = read_json(jpath) {
2854 if expected.is_empty() || run.tool.run_id == expected {
2855 return Some((jpath.clone(), run.tool.run_id));
2856 }
2857 }
2858 }
2859 None
2860}
2861
2862fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
2863 html_path
2864 .parent()
2865 .and_then(|p| p.parent())
2866 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
2867}
2868
2869fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
2870 let mut hits = collect_result_json_candidates(scan_root);
2871 if hits.is_empty() {
2872 hits = collect_result_json_candidates(parent);
2873 }
2874 hits.sort();
2875 hits
2876}
2877
2878#[allow(clippy::too_many_lines)]
2879async fn locate_report_handler(
2880 State(state): State<AppState>,
2881 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2882 headers: axum::http::HeaderMap,
2883 Form(form): Form<LocateReportForm>,
2884) -> impl IntoResponse {
2885 let want_json = headers
2886 .get(axum::http::header::ACCEPT)
2887 .and_then(|v| v.to_str().ok())
2888 .is_some_and(|v| v.contains("application/json"));
2889
2890 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
2891 Ok(v) => v,
2892 Err(resp) => {
2893 if want_json {
2894 return locate_handler_err(
2895 true,
2896 "No HTML report file found in the selected folder. \
2897 Make sure you selected the folder that contains your \
2898 scan output (look for the folder with html/, json/, pdf/ subdirs)."
2899 .to_string(),
2900 &csp_nonce,
2901 );
2902 }
2903 return resp;
2904 }
2905 };
2906
2907 let scan_root_owned = resolve_scan_root(&html_path, &parent);
2910 let scan_root: &Path = &scan_root_owned;
2911 let json_candidates = gather_json_candidates(scan_root, &parent);
2912
2913 let expected_run_id = form
2915 .expected_run_id
2916 .as_deref()
2917 .unwrap_or("")
2918 .trim()
2919 .to_string();
2920
2921 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
2922
2923 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
2925 let actual = json_candidates
2926 .iter()
2927 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
2928 .unwrap_or_else(|| "unknown".to_string());
2929 return locate_handler_err(
2930 want_json,
2931 format!(
2932 "This folder contains a different scan.\n\n\
2933 Expected run ID : {expected_run_id}\n\
2934 Found run ID : {actual}\n\n\
2935 Please select the folder that contains the correct scan output."
2936 ),
2937 &csp_nonce,
2938 );
2939 }
2940
2941 let safe_redirect = form
2942 .redirect_url
2943 .as_deref()
2944 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
2945 .unwrap_or("/view-reports?linked=1")
2946 .to_string();
2947
2948 let mut reg = state.registry.lock().await;
2949
2950 if let Some((json_path, run_id)) = matched_json {
2951 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
2953 entry.html_path = Some(html_path);
2954 entry.json_path = Some(json_path);
2955 let _ = reg.save(&state.registry_path);
2956 drop(reg);
2957 state.artifacts.lock().await.remove(&run_id);
2959 return redirect_or_json_ok(want_json, &safe_redirect);
2960 }
2961 match read_json(&json_path) {
2963 Ok(run) => {
2964 let entry = registry_entry_from_run(&run, json_path, html_path);
2965 reg.add_entry(entry);
2966 let _ = reg.save(&state.registry_path);
2967 drop(reg);
2968 state.artifacts.lock().await.remove(&run_id);
2969 return redirect_or_json_ok(want_json, &safe_redirect);
2970 }
2971 Err(e) => {
2972 drop(reg);
2973 return locate_handler_err(
2974 want_json,
2975 format!(
2976 "Found the scan folder but could not parse the result JSON.\n\n\
2977 The file may have been saved by an older version of OxideSLOC. \
2978 Re-running the analysis will create a fresh, compatible record.\n\n\
2979 Error: {e}"
2980 ),
2981 &csp_nonce,
2982 );
2983 }
2984 }
2985 }
2986
2987 if let Some(entry) = reg
2989 .entries
2990 .iter_mut()
2991 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
2992 {
2993 entry.html_path = Some(html_path.clone());
2994 let _ = reg.save(&state.registry_path);
2995 drop(reg);
2996 state.artifacts.lock().await.remove(&expected_run_id);
2997 return redirect_or_json_ok(want_json, &safe_redirect);
2998 }
2999
3000 drop(reg);
3001 let hint = if state.server_mode {
3002 String::new()
3003 } else {
3004 format!(
3005 "\n\nSearched folder : {}\nHTML found : {}",
3006 scan_root.display(),
3007 html_path.display()
3008 )
3009 };
3010 locate_handler_err(
3011 want_json,
3012 format!(
3013 "Could not link this report.\n\n\
3014 No result_*.json was found in the selected folder. \
3015 Make sure you selected the top-level scan output folder \
3016 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3017 ),
3018 &csp_nonce,
3019 )
3020}
3021
3022fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3024 fs::read_dir(dir)
3025 .ok()?
3026 .flatten()
3027 .map(|e| e.path())
3028 .find(|p| {
3029 p.is_file()
3030 && p.file_stem()
3031 .and_then(|n| n.to_str())
3032 .is_some_and(|n| n.starts_with("result"))
3033 && p.extension()
3034 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3035 })
3036}
3037
3038#[derive(Deserialize)]
3039struct LocateReportsDirForm {
3040 folder_path: String,
3041}
3042
3043#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3045 State(state): State<AppState>,
3046 Form(form): Form<LocateReportsDirForm>,
3047) -> impl IntoResponse {
3048 if state.server_mode {
3049 return StatusCode::NOT_FOUND.into_response();
3050 }
3051 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3052 Ok(p) => strip_unc_prefix(p),
3053 Err(_) => {
3054 return axum::response::Redirect::to(
3055 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3056 )
3057 .into_response();
3058 }
3059 };
3060 if !folder.is_dir() {
3061 return axum::response::Redirect::to(
3062 "/view-reports?error=Selected+path+is+not+a+directory.",
3063 )
3064 .into_response();
3065 }
3066
3067 let candidates = collect_result_json_candidates(&folder);
3068
3069 if candidates.is_empty() {
3070 return axum::response::Redirect::to(
3071 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3072 )
3073 .into_response();
3074 }
3075
3076 let mut linked_count: usize = 0;
3077 let mut reg = state.registry.lock().await;
3078 for json_path in candidates {
3079 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3080 continue;
3081 };
3082 if is_dir_already_registered(®, &parent) {
3083 continue;
3084 }
3085 let Some(entry) = build_registry_entry_from_json(json_path) else {
3086 continue;
3087 };
3088 reg.add_entry(entry);
3089 linked_count += 1;
3090 }
3091 let _ = reg.save(&state.registry_path);
3092 drop(reg);
3093
3094 if linked_count == 0 {
3095 return axum::response::Redirect::to(
3096 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3097 )
3098 .into_response();
3099 }
3100 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3101}
3102
3103#[derive(Deserialize)]
3104struct RelocateScanForm {
3105 run_id: String,
3106 folder_path: String,
3107 redirect_url: String,
3108}
3109
3110fn relocate_folder_err(
3113 want_json: bool,
3114 status: StatusCode,
3115 msg: &str,
3116 run_id: &str,
3117 folder_hint: &str,
3118 redirect_url: &str,
3119 csp_nonce: &str,
3120) -> Response {
3121 if want_json {
3122 (
3123 status,
3124 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3125 )
3126 .into_response()
3127 } else {
3128 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3129 }
3130}
3131
3132#[allow(clippy::too_many_lines)]
3133async fn relocate_scan_handler(
3134 State(state): State<AppState>,
3135 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3136 headers: axum::http::HeaderMap,
3137 Form(form): Form<RelocateScanForm>,
3138) -> impl IntoResponse {
3139 let want_json = headers
3140 .get(axum::http::header::ACCEPT)
3141 .and_then(|v| v.to_str().ok())
3142 .is_some_and(|v| v.contains("application/json"));
3143 if state.server_mode {
3144 return StatusCode::NOT_FOUND.into_response();
3145 }
3146
3147 let run_id = form.run_id.trim().to_string();
3148 let redirect_url = form.redirect_url.trim().to_string();
3149
3150 let run_exists = {
3151 let reg = state.registry.lock().await;
3152 reg.find_by_run_id(&run_id).is_some()
3153 };
3154 if !run_exists {
3155 if want_json {
3156 return (
3157 StatusCode::NOT_FOUND,
3158 axum::Json(serde_json::json!({
3159 "ok": false,
3160 "message": format!("Run ID '{run_id}' not found in registry.")
3161 })),
3162 )
3163 .into_response();
3164 }
3165 let html = ErrorTemplate {
3166 message: format!("Run ID '{run_id}' not found in registry."),
3167 last_report_url: Some("/compare-scans".to_string()),
3168 last_report_label: Some("Compare Scans".to_string()),
3169 run_id: Some(run_id.clone()),
3170 error_code: Some(404),
3171 csp_nonce: csp_nonce.clone(),
3172 version: env!("CARGO_PKG_VERSION"),
3173 }
3174 .render()
3175 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3176 return Html(html).into_response();
3177 }
3178
3179 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3180 Ok(p) => strip_unc_prefix(p),
3181 Err(_) => {
3182 return relocate_folder_err(
3183 want_json,
3184 StatusCode::UNPROCESSABLE_ENTITY,
3185 "Folder not found or path is invalid.",
3186 &run_id,
3187 form.folder_path.trim(),
3188 &redirect_url,
3189 &csp_nonce,
3190 );
3191 }
3192 };
3193 if !folder.is_dir() {
3194 return relocate_folder_err(
3195 want_json,
3196 StatusCode::UNPROCESSABLE_ENTITY,
3197 "Selected path is not a directory.",
3198 &run_id,
3199 &folder.display().to_string(),
3200 &redirect_url,
3201 &csp_nonce,
3202 );
3203 }
3204
3205 let json_candidates = find_result_files_by_ext(&folder, "json");
3206 if json_candidates.is_empty() {
3207 let msg = format!(
3208 "No result JSON files found in the selected folder.\nSearched: {}",
3209 folder.display()
3210 );
3211 return relocate_folder_err(
3212 want_json,
3213 StatusCode::UNPROCESSABLE_ENTITY,
3214 &msg,
3215 &run_id,
3216 &folder.display().to_string(),
3217 &redirect_url,
3218 &csp_nonce,
3219 );
3220 }
3221
3222 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3223 let msg = format!(
3224 "No matching scan found in the selected folder.\n\
3225 The JSON files present do not contain run ID: {run_id}\n\
3226 Searched: {}",
3227 folder.display()
3228 );
3229 return relocate_folder_err(
3230 want_json,
3231 StatusCode::UNPROCESSABLE_ENTITY,
3232 &msg,
3233 &run_id,
3234 &folder.display().to_string(),
3235 &redirect_url,
3236 &csp_nonce,
3237 );
3238 };
3239
3240 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3241 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3242 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3243
3244 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3245 redirect_url
3246 } else {
3247 "/compare-scans".to_string()
3248 };
3249 redirect_or_json_ok(want_json, &safe_redirect)
3250}
3251
3252fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3253 let mut out = Vec::new();
3254 collect_scan_files_by_ext(folder, ext, &mut out);
3255 if let Ok(rd) = fs::read_dir(folder) {
3256 for entry in rd.flatten() {
3257 let sub = entry.path();
3258 if sub.is_dir() {
3259 collect_scan_files_by_ext(&sub, ext, &mut out);
3260 }
3261 }
3262 }
3263 out
3264}
3265
3266fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3267 let Ok(rd) = fs::read_dir(dir) else { return };
3268 for entry in rd.flatten() {
3269 let p = entry.path();
3270 if p.is_file()
3271 && p.file_stem()
3272 .and_then(|n| n.to_str())
3273 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3274 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3275 {
3276 out.push(p);
3277 }
3278 }
3279}
3280
3281fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3282 candidates
3283 .iter()
3284 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3285 .cloned()
3286}
3287
3288async fn update_run_file_paths(
3289 state: &AppState,
3290 run_id: &str,
3291 json_path: PathBuf,
3292 html_path: Option<PathBuf>,
3293 pdf_path: Option<PathBuf>,
3294) {
3295 let mut reg = state.registry.lock().await;
3296 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3297 entry.json_path = Some(json_path);
3298 if let Some(hp) = html_path {
3299 entry.html_path = Some(hp);
3300 }
3301 if let Some(pp) = pdf_path {
3302 entry.pdf_path = Some(pp);
3303 }
3304 }
3305 let _ = reg.save(&state.registry_path);
3306}
3307
3308fn missing_scan_relocate_response(
3309 message: &str,
3310 run_id: &str,
3311 folder_hint: &str,
3312 redirect_url: &str,
3313 server_mode: bool,
3314 csp_nonce: &str,
3315) -> axum::response::Response {
3316 let html = RelocateScanTemplate {
3317 message: message.to_string(),
3318 run_id: run_id.to_string(),
3319 folder_hint: folder_hint.to_string(),
3320 redirect_url: redirect_url.to_string(),
3321 server_mode,
3322 csp_nonce: csp_nonce.to_owned(),
3323 version: env!("CARGO_PKG_VERSION"),
3324 }
3325 .render()
3326 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3327 (StatusCode::NOT_FOUND, Html(html)).into_response()
3328}
3329
3330fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3334 let mut candidates = Vec::new();
3335 if let Some(j) = find_result_json_in_dir(folder) {
3336 candidates.push(j);
3337 }
3338 if let Ok(dir_entries) = fs::read_dir(folder) {
3339 for entry in dir_entries.flatten() {
3340 let sub = entry.path();
3341 if sub.is_dir() {
3342 if let Some(j) = find_result_json_in_dir(&sub) {
3343 candidates.push(j);
3344 }
3345 }
3346 }
3347 }
3348 candidates
3349}
3350
3351fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3352 reg.entries.iter().any(|e| {
3353 let dir_match = e
3354 .json_path
3355 .as_ref()
3356 .and_then(|p| p.parent())
3357 .is_some_and(|p| p == parent)
3358 || e.html_path
3359 .as_ref()
3360 .and_then(|p| p.parent())
3361 .is_some_and(|p| p == parent);
3362 dir_match
3363 && (e.json_path.as_ref().is_some_and(|p| p.exists())
3364 || e.html_path.as_ref().is_some_and(|p| p.exists()))
3365 })
3366}
3367
3368fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3369 let parent = json_path.parent()?.to_path_buf();
3370 let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
3371 rd.flatten()
3372 .map(|e| e.path())
3373 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3374 });
3375 let run = read_json(&json_path).ok()?;
3376 let project_label = run.input_roots.first().map_or_else(
3377 || "Unknown Project".to_string(),
3378 |r| sanitize_project_label(r),
3379 );
3380 Some(RegistryEntry {
3381 run_id: run.tool.run_id.clone(),
3382 timestamp_utc: run.tool.timestamp_utc,
3383 project_label,
3384 input_roots: run.input_roots.clone(),
3385 json_path: Some(json_path),
3386 html_path,
3387 pdf_path: None,
3388 csv_path: None,
3389 xlsx_path: None,
3390 summary: ScanSummarySnapshot {
3391 files_analyzed: run.summary_totals.files_analyzed,
3392 files_skipped: run.summary_totals.files_skipped,
3393 total_physical_lines: run.summary_totals.total_physical_lines,
3394 code_lines: run.summary_totals.code_lines,
3395 comment_lines: run.summary_totals.comment_lines,
3396 blank_lines: run.summary_totals.blank_lines,
3397 functions: run.summary_totals.functions,
3398 classes: run.summary_totals.classes,
3399 variables: run.summary_totals.variables,
3400 imports: run.summary_totals.imports,
3401 test_count: run.summary_totals.test_count,
3402 coverage_lines_found: run.summary_totals.coverage_lines_found,
3403 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3404 coverage_functions_found: run.summary_totals.coverage_functions_found,
3405 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3406 coverage_branches_found: run.summary_totals.coverage_branches_found,
3407 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3408 },
3409 git_branch: run.git_branch.clone(),
3410 git_commit: run.git_commit_short.clone(),
3411 git_author: run.git_commit_author.clone(),
3412 git_tags: run.git_tags.clone(),
3413 git_nearest_tag: run.git_nearest_tag.clone(),
3414 git_commit_date: run.git_commit_date,
3415 })
3416}
3417
3418fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3421 let mut linked = 0usize;
3422 for json_path in collect_result_json_candidates(folder) {
3423 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3424 continue;
3425 };
3426 if is_dir_already_registered(reg, &parent) {
3427 continue;
3428 }
3429 let Some(entry) = build_registry_entry_from_json(json_path) else {
3430 continue;
3431 };
3432 reg.add_entry(entry);
3433 linked += 1;
3434 }
3435 linked
3436}
3437
3438async fn auto_scan_watched_dirs(state: &AppState) {
3440 let dirs: Vec<PathBuf> = {
3441 let wd = state.watched_dirs.lock().await;
3442 wd.dirs.clone()
3443 };
3444 if dirs.is_empty() {
3445 return;
3446 }
3447 let mut reg = state.registry.lock().await;
3448 let mut total = 0usize;
3449 for dir in &dirs {
3450 if dir.is_dir() {
3451 total += scan_folder_into_registry(dir, &mut reg);
3452 }
3453 }
3454 if total > 0 {
3455 let _ = reg.save(&state.registry_path);
3456 }
3457}
3458
3459#[derive(Deserialize)]
3462struct WatchedDirForm {
3463 folder_path: String,
3464 #[serde(default = "default_redirect")]
3465 redirect_to: String,
3466}
3467
3468fn default_redirect() -> String {
3469 "/view-reports".to_string()
3470}
3471
3472#[derive(Deserialize)]
3473struct WatchedDirRefreshForm {
3474 #[serde(default = "default_redirect")]
3475 redirect_to: String,
3476}
3477
3478fn safe_redirect(dest: &str) -> &str {
3482 if dest.starts_with('/') {
3483 dest
3484 } else {
3485 "/"
3486 }
3487}
3488
3489async fn add_watched_dir_handler(
3492 State(state): State<AppState>,
3493 Form(form): Form<WatchedDirForm>,
3494) -> impl IntoResponse {
3495 if state.server_mode {
3496 return StatusCode::NOT_FOUND.into_response();
3497 }
3498 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3499 strip_unc_prefix(p)
3500 } else {
3501 let dest = format!(
3502 "{}?error=Folder+not+found+or+path+is+invalid.",
3503 safe_redirect(&form.redirect_to)
3504 );
3505 return axum::response::Redirect::to(&dest).into_response();
3506 };
3507 if !folder.is_dir() {
3508 let dest = format!(
3509 "{}?error=Selected+path+is+not+a+directory.",
3510 safe_redirect(&form.redirect_to)
3511 );
3512 return axum::response::Redirect::to(&dest).into_response();
3513 }
3514
3515 {
3517 let mut wd = state.watched_dirs.lock().await;
3518 wd.add(folder.clone());
3519 let _ = wd.save(&state.watched_dirs_path);
3520 }
3521
3522 let linked = {
3524 let mut reg = state.registry.lock().await;
3525 let n = scan_folder_into_registry(&folder, &mut reg);
3526 if n > 0 {
3527 let _ = reg.save(&state.registry_path);
3528 }
3529 n
3530 };
3531
3532 let dest = if linked > 0 {
3533 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3534 } else {
3535 format!(
3536 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3537 safe_redirect(&form.redirect_to)
3538 )
3539 };
3540 axum::response::Redirect::to(&dest).into_response()
3541}
3542
3543async fn remove_watched_dir_handler(
3544 State(state): State<AppState>,
3545 Form(form): Form<WatchedDirForm>,
3546) -> impl IntoResponse {
3547 if state.server_mode {
3548 return StatusCode::NOT_FOUND.into_response();
3549 }
3550 let folder = PathBuf::from(&form.folder_path);
3551 {
3552 let mut wd = state.watched_dirs.lock().await;
3553 wd.remove(&folder);
3554 let _ = wd.save(&state.watched_dirs_path);
3555 }
3556 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
3557}
3558
3559async fn refresh_watched_dirs_handler(
3560 State(state): State<AppState>,
3561 Form(form): Form<WatchedDirRefreshForm>,
3562) -> impl IntoResponse {
3563 if state.server_mode {
3564 return StatusCode::NOT_FOUND.into_response();
3565 }
3566 let dirs: Vec<PathBuf> = {
3567 let wd = state.watched_dirs.lock().await;
3568 wd.dirs.clone()
3569 };
3570 let mut total = 0usize;
3571 {
3572 let mut reg = state.registry.lock().await;
3573 for dir in &dirs {
3574 if dir.is_dir() {
3575 total += scan_folder_into_registry(dir, &mut reg);
3576 }
3577 }
3578 if total > 0 {
3579 let _ = reg.save(&state.registry_path);
3580 }
3581 }
3582 let dest = if total > 0 {
3583 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
3584 } else {
3585 safe_redirect(&form.redirect_to).to_owned()
3586 };
3587 axum::response::Redirect::to(&dest).into_response()
3588}
3589
3590#[derive(Debug, Deserialize)]
3591struct OpenPathQuery {
3592 path: Option<String>,
3593}
3594
3595fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3596 let mut ancestor = std::path::Path::new(raw);
3597 loop {
3598 match ancestor.parent() {
3599 Some(p) => {
3600 ancestor = p;
3601 if ancestor.is_dir() {
3602 break;
3603 }
3604 }
3605 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
3606 }
3607 }
3608 Ok(ancestor.to_path_buf())
3609}
3610
3611async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
3612 match tokio::fs::canonicalize(raw).await {
3613 Ok(canonical) if canonical.is_file() => canonical
3614 .parent()
3615 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
3616 Ok(p.to_path_buf())
3617 }),
3618 Ok(canonical) if canonical.is_dir() => Ok(canonical),
3619 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
3620 Err(_) => find_existing_ancestor(raw),
3621 }
3622}
3623
3624async fn open_path_handler(
3625 State(state): State<AppState>,
3626 Query(query): Query<OpenPathQuery>,
3627) -> impl IntoResponse {
3628 if state.server_mode {
3629 return Json(serde_json::json!({
3630 "server_mode_disabled": true,
3631 "message": "Opening a path in the file manager is only available in local desktop mode."
3632 }))
3633 .into_response();
3634 }
3635 if std::env::var("SLOC_HEADLESS").is_ok() {
3637 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
3638 }
3639 let raw = match query.path.as_deref() {
3640 Some(p) if !p.is_empty() => p,
3641 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
3642 };
3643
3644 let target = match resolve_open_target(raw).await {
3648 Ok(p) => p,
3649 Err((code, msg)) => return (code, msg).into_response(),
3650 };
3651
3652 #[cfg(target_os = "windows")]
3653 win_dialog_focus::open_folder_foreground(target);
3654 #[cfg(target_os = "macos")]
3655 let _ = std::process::Command::new("open")
3656 .arg(&target)
3657 .stdout(Stdio::null())
3658 .stderr(Stdio::null())
3659 .spawn();
3660 #[cfg(target_os = "linux")]
3661 {
3662 let folder_name = target
3663 .file_name()
3664 .and_then(|n| n.to_str())
3665 .map(str::to_owned);
3666 let _ = std::process::Command::new("xdg-open")
3667 .arg(&target)
3668 .stdout(Stdio::null())
3669 .stderr(Stdio::null())
3670 .spawn();
3671 if let Some(name) = folder_name {
3675 std::thread::spawn(move || {
3676 std::thread::sleep(std::time::Duration::from_millis(800));
3677 let _ = std::process::Command::new("wmctrl")
3678 .args(["-a", &name])
3679 .stdout(Stdio::null())
3680 .stderr(Stdio::null())
3681 .spawn();
3682 });
3683 }
3684 }
3685
3686 Json(serde_json::json!({"ok": true})).into_response()
3687}
3688
3689async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
3690 let (content_type, bytes): (&'static str, &'static [u8]) =
3691 match (folder.as_str(), file.as_str()) {
3692 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
3693 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
3694 ("icons", "c.png") => ("image/png", IMG_ICON_C),
3695 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
3696 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
3697 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
3698 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
3699 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
3700 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
3701 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
3702 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
3703 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
3704 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
3705 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
3706 ("icons", "r.png") => ("image/png", IMG_ICON_R),
3707 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
3708 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
3709 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
3710 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
3711 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
3712 _ => return StatusCode::NOT_FOUND.into_response(),
3713 };
3714 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
3715}
3716
3717async fn preview_handler(
3718 State(state): State<AppState>,
3719 Query(query): Query<PreviewQuery>,
3720) -> impl IntoResponse {
3721 let raw_path = query
3722 .path
3723 .unwrap_or_else(|| "tests/fixtures/basic".to_string());
3724 let resolved = resolve_input_path(&raw_path);
3725
3726 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
3730 return Html(
3731 r#"<div class="preview-error">Sample directory not available on this server.
3732 Enter a path to a project directory or upload files using Browse.</div>"#
3733 .to_string(),
3734 );
3735 }
3736
3737 if state.server_mode {
3738 let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
3739 if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
3741 let config = &state.base_config;
3742 if config.discovery.allowed_scan_roots.is_empty() {
3743 return Html(
3744 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
3745 );
3746 }
3747 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3748 fs::canonicalize(root)
3749 .ok()
3750 .is_some_and(|r| canonical.starts_with(&r))
3751 });
3752 if !allowed {
3753 return Html(
3754 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
3755 );
3756 }
3757 }
3758 }
3759
3760 let include_patterns = split_patterns(query.include_globs.as_deref());
3761 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
3762
3763 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
3764 Ok(html) => Html(html),
3765 Err(err) => Html(format!(
3766 r#"<div class="preview-error">Preview failed: {}</div>"#,
3767 escape_html(&err.to_string())
3768 )),
3769 }
3770}
3771
3772#[derive(Debug, Deserialize, Default)]
3773struct SuggestCoverageQuery {
3774 path: Option<String>,
3775}
3776
3777#[derive(Serialize)]
3778struct SuggestCoverageResponse {
3779 found: Option<String>,
3780 tool: Option<&'static str>,
3781 hint: Option<&'static str>,
3782}
3783
3784async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
3785 const CANDIDATES: &[&str] = &[
3786 "coverage/lcov.info",
3788 "lcov.info",
3789 "target/llvm-cov/lcov.info",
3790 "target/coverage/lcov.info",
3791 "target/debug/coverage/lcov.info",
3792 "coverage/coverage.lcov",
3793 "build/coverage/lcov.info",
3794 "reports/lcov.info",
3795 "coverage.xml",
3797 "coverage/coverage.xml",
3798 "target/site/cobertura/coverage.xml",
3799 "build/reports/coverage/coverage.xml",
3800 "target/site/jacoco/jacoco.xml",
3802 "build/reports/jacoco/test/jacocoTestReport.xml",
3803 "build/reports/jacoco/jacocoTestReport.xml",
3804 "build/jacoco/jacoco.xml",
3805 ];
3806 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
3807 let found = CANDIDATES
3808 .iter()
3809 .map(|rel| root.join(rel))
3810 .find(|p| p.is_file())
3811 .map(|p| display_path(&p));
3812
3813 let (tool, hint) = detect_coverage_tool(&root);
3814 Json(SuggestCoverageResponse { found, tool, hint })
3815}
3816
3817fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
3820 if root.join("Cargo.toml").is_file() {
3821 return (
3822 Some("cargo-llvm-cov"),
3823 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
3824 );
3825 }
3826 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
3827 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
3828 }
3829 if root.join("pom.xml").is_file() {
3830 return (Some("jacoco"), Some("mvn test jacoco:report"));
3831 }
3832 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
3833 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
3834 }
3835 (None, None)
3836}
3837
3838#[allow(clippy::result_large_err)]
3840fn validate_server_scan_path(
3841 config: &sloc_config::AppConfig,
3842 resolved_path: &Path,
3843 csp_nonce: &str,
3844) -> Result<(), Response> {
3845 if config.discovery.allowed_scan_roots.is_empty() {
3846 let template = ErrorTemplate {
3847 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
3848 Set allowed_scan_roots in the server config to permit scanning."
3849 .to_string(),
3850 last_report_url: None,
3851 last_report_label: None,
3852 run_id: None,
3853 error_code: Some(403),
3854 csp_nonce: csp_nonce.to_owned(),
3855 version: env!("CARGO_PKG_VERSION"),
3856 };
3857 return Err((
3858 StatusCode::FORBIDDEN,
3859 Html(
3860 template
3861 .render()
3862 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
3863 ),
3864 )
3865 .into_response());
3866 }
3867 let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
3868 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
3869 fs::canonicalize(root)
3870 .ok()
3871 .is_some_and(|r| canonical.starts_with(&r))
3872 });
3873 if !allowed {
3874 tracing::warn!(event = "path_rejected", path = %canonical.display(),
3875 "Scan path not in allowed_scan_roots");
3876 let template = ErrorTemplate {
3877 message: "The requested path is not within an allowed scan directory.".to_string(),
3878 last_report_url: None,
3879 last_report_label: None,
3880 run_id: None,
3881 error_code: Some(403),
3882 csp_nonce: csp_nonce.to_owned(),
3883 version: env!("CARGO_PKG_VERSION"),
3884 };
3885 return Err((
3886 StatusCode::FORBIDDEN,
3887 Html(
3888 template
3889 .render()
3890 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
3891 ),
3892 )
3893 .into_response());
3894 }
3895 Ok(())
3896}
3897
3898fn apply_output_dir_exclusions(
3900 config: &mut sloc_config::AppConfig,
3901 project_path: &str,
3902 raw_output_dir: &str,
3903) {
3904 let project_root = resolve_input_path(project_path);
3905 let raw_out = raw_output_dir.trim();
3906 let resolved_out = if raw_out.is_empty() {
3907 project_root.join("sloc")
3908 } else if Path::new(raw_out).is_absolute() {
3909 PathBuf::from(raw_out)
3910 } else {
3911 workspace_root().join(raw_out)
3912 };
3913 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
3914 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
3915 let dir = first.to_string();
3916 if !config.discovery.excluded_directories.contains(&dir) {
3917 config.discovery.excluded_directories.push(dir);
3918 }
3919 }
3920 }
3921 if !config
3922 .discovery
3923 .excluded_directories
3924 .iter()
3925 .any(|d| d == "sloc")
3926 {
3927 config
3928 .discovery
3929 .excluded_directories
3930 .push("sloc".to_string());
3931 }
3932}
3933
3934const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
3936 ScanSummarySnapshot {
3937 files_analyzed: run.summary_totals.files_analyzed,
3938 files_skipped: run.summary_totals.files_skipped,
3939 total_physical_lines: run.summary_totals.total_physical_lines,
3940 code_lines: run.summary_totals.code_lines,
3941 comment_lines: run.summary_totals.comment_lines,
3942 blank_lines: run.summary_totals.blank_lines,
3943 functions: run.summary_totals.functions,
3944 classes: run.summary_totals.classes,
3945 variables: run.summary_totals.variables,
3946 imports: run.summary_totals.imports,
3947 test_count: run.summary_totals.test_count,
3948 coverage_lines_found: run.summary_totals.coverage_lines_found,
3949 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
3950 coverage_functions_found: run.summary_totals.coverage_functions_found,
3951 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
3952 coverage_branches_found: run.summary_totals.coverage_branches_found,
3953 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
3954 }
3955}
3956
3957pub(crate) fn build_run_registry_entry(
3959 run: &AnalysisRun,
3960 run_id: &str,
3961 project_label: &str,
3962 artifacts: &RunArtifacts,
3963) -> RegistryEntry {
3964 RegistryEntry {
3965 run_id: run_id.to_owned(),
3966 timestamp_utc: run.tool.timestamp_utc,
3967 project_label: project_label.to_owned(),
3968 input_roots: run.input_roots.clone(),
3969 json_path: artifacts.json_path.clone(),
3970 html_path: artifacts.html_path.clone(),
3971 pdf_path: artifacts.pdf_path.clone(),
3972 csv_path: artifacts.csv_path.clone(),
3973 xlsx_path: artifacts.xlsx_path.clone(),
3974 summary: summary_snapshot_from_run(run),
3975 git_branch: run.git_branch.clone(),
3976 git_commit: run.git_commit_short.clone(),
3977 git_author: run.git_commit_author.clone(),
3978 git_tags: run.git_tags.clone(),
3979 git_nearest_tag: run.git_nearest_tag.clone(),
3980 git_commit_date: run.git_commit_date.clone(),
3981 }
3982}
3983
3984fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
3986 if let Some(policy) = form.mixed_line_policy {
3987 config.analysis.mixed_line_policy = policy;
3988 }
3989 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
3990 config.analysis.generated_file_detection =
3991 form.generated_file_detection.as_deref() != Some("disabled");
3992 config.analysis.minified_file_detection =
3993 form.minified_file_detection.as_deref() != Some("disabled");
3994 config.analysis.vendor_directory_detection =
3995 form.vendor_directory_detection.as_deref() != Some("disabled");
3996 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
3997 if let Some(binary_behavior) = form.binary_file_behavior {
3998 config.analysis.binary_file_behavior = binary_behavior;
3999 }
4000 apply_report_opts(config, form);
4001 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4002 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4003 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4004 if let Some(policy) = form.continuation_line_policy {
4005 config.analysis.continuation_line_policy = policy;
4006 }
4007 if let Some(policy) = form.blank_in_block_comment_policy {
4008 config.analysis.blank_in_block_comment_policy = policy;
4009 }
4010 config.analysis.count_compiler_directives =
4011 form.count_compiler_directives.as_deref() != Some("disabled");
4012 apply_style_threshold(config, form);
4013 apply_coverage_path(config, form);
4014}
4015
4016fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4017 if let Some(report_title) = form.report_title.as_deref() {
4018 let trimmed = report_title.trim();
4019 if !trimmed.is_empty() {
4020 config.reporting.report_title = trimmed.to_string();
4021 }
4022 }
4023 if let Some(hf) = form.report_header_footer.as_deref() {
4024 let trimmed = hf.trim();
4025 config.reporting.report_header_footer = if trimmed.is_empty() {
4026 None
4027 } else {
4028 Some(trimmed.to_string())
4029 };
4030 }
4031}
4032
4033fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4034 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4035 if let Ok(t) = threshold_str.parse::<u16>() {
4036 if t == 80 || t == 100 || t == 120 {
4037 config.analysis.style_col_threshold = t;
4038 }
4039 }
4040 }
4041 if let Some(v) = form.style_analysis_enabled.as_deref() {
4042 config.analysis.style_analysis_enabled = v != "disabled";
4043 }
4044 if let Some(v) = form.style_score_threshold.as_deref() {
4045 if let Ok(t) = v.parse::<u8>() {
4046 config.analysis.style_score_threshold = t.min(100);
4047 }
4048 }
4049 if let Some(v) = form.style_lang_scope.as_deref() {
4050 let scope = v.trim();
4051 if scope == "c_family" || scope == "all" {
4052 config.analysis.style_lang_scope = scope.to_string();
4053 }
4054 }
4055}
4056
4057fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4058 if let Some(cov) = &form.coverage_file {
4059 let trimmed = cov.trim();
4060 if !trimmed.is_empty() {
4061 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4062 }
4063 }
4064}
4065
4066fn spawn_pdf_background(
4070 pending_pdf: PendingPdf,
4071 run_id: String,
4072 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4073) {
4074 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4075 tokio::spawn(async move {
4076 let result = tokio::task::spawn_blocking(move || {
4077 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4078 if cleanup_src {
4079 let _ = fs::remove_file(&pdf_src);
4080 }
4081 r
4082 })
4083 .await;
4084 let failed = match result {
4085 Ok(Ok(())) => false,
4086 Ok(Err(err)) => {
4087 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4088 true
4089 }
4090 Err(err) => {
4091 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4092 true
4093 }
4094 };
4095 if failed {
4096 let mut map = artifacts.lock().await;
4097 if let Some(entry) = map.get_mut(&run_id) {
4098 entry.pdf_path = None;
4099 }
4100 }
4101 });
4102 }
4103}
4104
4105fn spawn_native_pdf_background(
4109 json_path: PathBuf,
4110 pdf_dest: PathBuf,
4111 run_id: String,
4112 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4113) {
4114 tokio::spawn(async move {
4115 let result = tokio::task::spawn_blocking(move || {
4116 let run = sloc_core::read_json(&json_path)?;
4117 write_pdf_from_run(&run, &pdf_dest)
4118 })
4119 .await;
4120 let failed = match result {
4121 Ok(Ok(())) => false,
4122 Ok(Err(err)) => {
4123 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4124 true
4125 }
4126 Err(err) => {
4127 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4128 true
4129 }
4130 };
4131 if failed {
4132 let mut map = artifacts.lock().await;
4133 if let Some(entry) = map.get_mut(&run_id) {
4134 entry.pdf_path = None;
4135 }
4136 }
4137 });
4138}
4139
4140fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4142 cmp.file_deltas
4143 .iter()
4144 .map(|f| match f.status {
4145 FileChangeStatus::Added => f.current_code,
4146 FileChangeStatus::Modified => f.code_delta.max(0),
4147 _ => 0,
4148 })
4149 .sum()
4150}
4151
4152fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4154 cmp.file_deltas
4155 .iter()
4156 .map(|f| match f.status {
4157 FileChangeStatus::Removed => f.baseline_code,
4158 FileChangeStatus::Modified => (-f.code_delta).max(0),
4159 _ => 0,
4160 })
4161 .sum()
4162}
4163
4164fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4166 cmp.file_deltas
4167 .iter()
4168 .filter(|f| f.status == FileChangeStatus::Unchanged)
4169 .map(|f| f.current_code)
4170 .sum()
4171}
4172
4173fn build_submodule_row(
4175 s: &sloc_core::SubmoduleSummary,
4176 run: &AnalysisRun,
4177 run_id: &str,
4178 run_dir: &Path,
4179) -> SubmoduleRow {
4180 let safe = sanitize_project_label(&s.name);
4181 let artifact_key = format!("sub_{safe}");
4182 let pdf_artifact_key = format!("sub_{safe}_pdf");
4183 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4184 let parent_path = run
4185 .input_roots
4186 .first()
4187 .map_or("", std::string::String::as_str);
4188 let sub_run = build_sub_run(run, s, parent_path);
4189 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4190 render_sub_report_html(&sub_run, Some(&pdf_server_url))
4191 .ok()
4192 .and_then(|sub_html| {
4193 let sub_dir = run_dir.join("submodules");
4194 let _ = fs::create_dir_all(&sub_dir);
4195 let html_path = sub_dir.join(format!("{artifact_key}.html"));
4196 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4197 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4200 let _ = write_pdf_from_run(&sub_run, &pdf_path);
4201 Some(format!("/runs/{artifact_key}/{run_id}"))
4202 } else {
4203 None
4204 }
4205 })
4206 } else {
4207 None
4208 };
4209 SubmoduleRow {
4210 name: s.name.clone(),
4211 relative_path: s.relative_path.clone(),
4212 files_analyzed: s.files_analyzed,
4213 code_lines: s.code_lines,
4214 comment_lines: s.comment_lines,
4215 blank_lines: s.blank_lines,
4216 total_physical_lines: s.total_physical_lines,
4217 html_url,
4218 }
4219}
4220
4221#[allow(clippy::similar_names)]
4224#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
4226async fn analyze_handler(
4227 State(state): State<AppState>,
4228 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4229 Form(form): Form<AnalyzeForm>,
4230) -> impl IntoResponse {
4231 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4232 let template = ErrorTemplate {
4233 message: format!(
4234 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4235 Please wait a moment and try again."
4236 ),
4237 last_report_url: None,
4238 last_report_label: None,
4239 run_id: None,
4240 error_code: Some(503),
4241 csp_nonce: csp_nonce.clone(),
4242 version: env!("CARGO_PKG_VERSION"),
4243 };
4244 return (
4245 StatusCode::SERVICE_UNAVAILABLE,
4246 Html(
4247 template
4248 .render()
4249 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4250 ),
4251 )
4252 .into_response();
4253 };
4254
4255 let mut config = state.base_config.clone();
4256
4257 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4258 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4259 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4260
4261 if !is_git_mode {
4262 let resolved_path = resolve_input_path(&form.path);
4263 if state.server_mode
4264 && !is_upload_tmp_path(&resolved_path)
4265 && !is_sample_path(&resolved_path)
4266 {
4267 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4268 return resp;
4269 }
4270 }
4271 config.discovery.root_paths = vec![resolved_path];
4272 }
4273
4274 apply_form_to_config(&mut config, &form);
4275 apply_output_dir_exclusions(
4276 &mut config,
4277 &form.path,
4278 form.output_dir.as_deref().unwrap_or(""),
4279 );
4280
4281 let wait_id = uuid::Uuid::new_v4().to_string();
4283 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4284
4285 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4287 let task_cancel = Arc::clone(&cancel_token);
4288
4289 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4291 let task_phase = Arc::clone(&phase);
4292
4293 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4294 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4295 let task_files_done = Arc::clone(&files_done);
4296 let task_files_total = Arc::clone(&files_total);
4297
4298 {
4301 let mut runs = state.async_runs.lock().await;
4302 runs.insert(
4303 wait_id.clone(),
4304 AsyncRunState::Running {
4305 started_at: std::time::Instant::now(),
4306 cancel_token,
4307 phase,
4308 files_done,
4309 files_total,
4310 },
4311 );
4312 }
4313
4314 let task = AnalysisTask {
4315 sem_permit,
4316 state: state.clone(),
4317 wait_id: wait_id.clone(),
4318 config,
4319 cancel: task_cancel,
4320 phase: task_phase,
4321 files_done: task_files_done,
4322 files_total: task_files_total,
4323 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4324 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4325 project_path: form.path.clone(),
4326 output_dir: if state.server_mode {
4330 None
4331 } else {
4332 form.output_dir.clone()
4333 },
4334 clones_dir: state.git_clones_dir.clone(),
4335 cocomo_mode: form
4336 .cocomo_mode
4337 .clone()
4338 .unwrap_or_else(|| "organic".to_string()),
4339 complexity_alert: form
4340 .complexity_alert
4341 .as_deref()
4342 .and_then(|s| s.parse::<u32>().ok())
4343 .unwrap_or(0),
4344 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4345 };
4346
4347 tokio::spawn(run_analysis_task(task));
4348
4349 let template = ScanWaitTemplate {
4350 version: env!("CARGO_PKG_VERSION"),
4351 wait_id_json,
4352 project_path: form.path.clone(),
4353 csp_nonce,
4354 };
4355 let html = template
4356 .render()
4357 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4358 let mut response = Html(html).into_response();
4359 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4360 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4361 response.headers_mut().insert(name, val);
4362 }
4363 }
4364 response
4365}
4366
4367struct AnalysisTask {
4368 sem_permit: tokio::sync::OwnedSemaphorePermit,
4369 state: AppState,
4370 wait_id: String,
4371 config: AppConfig,
4372 cancel: Arc<std::sync::atomic::AtomicBool>,
4373 phase: Arc<std::sync::Mutex<String>>,
4374 files_done: Arc<std::sync::atomic::AtomicUsize>,
4375 files_total: Arc<std::sync::atomic::AtomicUsize>,
4376 git_repo: Option<String>,
4377 git_ref: Option<String>,
4378 project_path: String,
4379 output_dir: Option<String>,
4380 clones_dir: PathBuf,
4381 cocomo_mode: String,
4382 complexity_alert: u32,
4383 exclude_duplicates: bool,
4384}
4385
4386#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
4388 let _permit = task.sem_permit;
4389
4390 let cancel_sb = Arc::clone(&task.cancel);
4391 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4392 let clones_dir_sb = task.clones_dir;
4393 let upload_staging_root = task
4395 .config
4396 .discovery
4397 .root_paths
4398 .first()
4399 .filter(|p| is_upload_tmp_path(p))
4400 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4401 .map(PathBuf::from);
4402 let config_sb = task.config;
4403 let progress_sb = sloc_core::ProgressCounters {
4404 files_done: Arc::clone(&task.files_done),
4405 files_total: Arc::clone(&task.files_total),
4406 };
4407 if let Ok(mut p) = task.phase.lock() {
4408 *p = "Scanning files".to_string();
4409 }
4410 let analysis_result = tokio::task::spawn_blocking(move || {
4411 run_analysis_blocking(
4412 config_sb,
4413 git_repo_sb,
4414 git_ref_sb,
4415 clones_dir_sb,
4416 cancel_sb,
4417 Some(progress_sb),
4418 )
4419 })
4420 .await
4421 .map_err(|err| anyhow::anyhow!(err.to_string()))
4422 .and_then(|result| result);
4423
4424 if let Ok(mut p) = task.phase.lock() {
4425 *p = "Writing reports".to_string();
4426 }
4427
4428 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4430 let mut runs = task.state.async_runs.lock().await;
4431 if matches!(
4433 runs.get(&task.wait_id),
4434 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4435 ) {
4436 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4437 }
4438 drop(runs);
4439 return;
4440 }
4441
4442 let run = match analysis_result {
4443 Ok(v) => v,
4444 Err(err) => {
4445 if err.to_string().contains("analysis cancelled") {
4447 let mut runs = task.state.async_runs.lock().await;
4448 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4449 drop(runs);
4450 return;
4451 }
4452 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4453 let mut runs = task.state.async_runs.lock().await;
4454 runs.insert(
4455 task.wait_id.clone(),
4456 AsyncRunState::Failed {
4457 message: "Analysis failed. Check that the path exists and is readable."
4458 .to_string(),
4459 },
4460 );
4461 drop(runs);
4462 return;
4463 }
4464 };
4465
4466 let run_id = run.tool.run_id.clone();
4467 tracing::info!(event = "scan_complete", run_id = %run_id,
4468 path = %task.project_path, files = run.summary_totals.files_analyzed,
4469 "Analysis finished");
4470
4471 let prev_entry: Option<RegistryEntry> = {
4472 let reg = task.state.registry.lock().await;
4473 reg.entries_for_roots(&run.input_roots)
4474 .into_iter()
4475 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4476 .cloned()
4477 };
4478
4479 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4480 prev.json_path
4481 .as_ref()
4482 .and_then(|p| read_json(p).ok())
4483 .map(|prev_run| compute_delta(&prev_run, &run))
4484 });
4485 let prev_scan_count: usize = {
4486 let reg = task.state.registry.lock().await;
4487 reg.entries_for_roots(&run.input_roots)
4488 .iter()
4489 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
4490 .count()
4491 };
4492
4493 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
4496 .as_ref()
4497 .zip(prev_entry.as_ref())
4498 .map(|(cmp, prev)| ReportDeltaContext {
4499 delta_code_added: sum_added_code_lines(cmp),
4500 delta_code_removed: sum_removed_code_lines(cmp),
4501 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
4502 delta_files_added: cmp.files_added,
4503 delta_files_removed: cmp.files_removed,
4504 delta_files_modified: cmp.files_modified,
4505 delta_files_unchanged: cmp.files_unchanged,
4506 prev_code_lines: prev.summary.code_lines,
4507 prev_scan_count: prev_scan_count + 1,
4508 prev_scan_label: fmt_la_time(prev.timestamp_utc),
4509 prev_run_id: Some(prev.run_id.clone()),
4510 current_run_id: Some(run_id.clone()),
4511 });
4512 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
4513 Ok(h) => h,
4514 Err(err) => {
4515 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
4516 let mut runs = task.state.async_runs.lock().await;
4517 runs.insert(
4518 task.wait_id.clone(),
4519 AsyncRunState::Failed {
4520 message: "Failed to render HTML report.".to_string(),
4521 },
4522 );
4523 drop(runs);
4524 return;
4525 }
4526 };
4527
4528 let output_root = resolve_output_root(task.output_dir.as_deref());
4529 let project_label = derive_project_label(
4530 task.git_repo.as_deref(),
4531 task.git_ref.as_deref(),
4532 &task.project_path,
4533 );
4534 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
4535 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
4536
4537 let result_context = RunResultContext {
4538 prev_entry: prev_entry.clone(),
4539 prev_scan_count,
4540 project_path: task.project_path.clone(),
4541 cocomo_mode: task.cocomo_mode.clone(),
4542 complexity_alert: task.complexity_alert,
4543 exclude_duplicates: task.exclude_duplicates,
4544 };
4545
4546 let artifact_result = persist_run_artifacts(
4547 &run,
4548 &report_html,
4549 &run_dir,
4550 &run.effective_configuration.reporting.report_title,
4551 &file_stem,
4552 result_context,
4553 );
4554
4555 let (artifacts, pending_pdf) = match artifact_result {
4556 Ok(v) => v,
4557 Err(err) => {
4558 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
4559 let mut runs = task.state.async_runs.lock().await;
4560 runs.insert(
4561 task.wait_id.clone(),
4562 AsyncRunState::Failed {
4563 message: "Failed to save report artifacts. Check available disk space."
4564 .to_string(),
4565 },
4566 );
4567 drop(runs);
4568 return;
4569 }
4570 };
4571
4572 {
4573 let mut map = task.state.artifacts.lock().await;
4574 map.insert(run_id.clone(), artifacts.clone());
4575 }
4576
4577 {
4578 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
4579 let mut reg = task.state.registry.lock().await;
4580 reg.add_entry(entry);
4581 let _ = reg.save(&task.state.registry_path);
4582 }
4583
4584 if let Some(ref cfg_path) = artifacts.scan_config_path {
4585 save_scan_config_json(
4586 cfg_path,
4587 &run,
4588 &task.project_path,
4589 task.output_dir.as_deref(),
4590 );
4591 }
4592
4593 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
4594
4595 prom_runs_total().inc();
4596
4597 let mut runs = task.state.async_runs.lock().await;
4599 runs.insert(
4600 task.wait_id.clone(),
4601 AsyncRunState::Complete {
4602 run_id: run_id.clone(),
4603 },
4604 );
4605 drop(runs);
4606
4607 if let Some(staging) = upload_staging_root {
4610 let _ = tokio::fs::remove_dir_all(staging).await;
4611 }
4612
4613 let _ = scan_delta;
4614}
4615
4616fn save_scan_config_json(
4617 cfg_path: &std::path::Path,
4618 run: &sloc_core::AnalysisRun,
4619 project_path: &str,
4620 output_dir: Option<&str>,
4621) {
4622 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
4623 .ok()
4624 .and_then(|v| v.as_str().map(String::from))
4625 .unwrap_or_else(|| "code_only".to_string());
4626 let behavior_str =
4627 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
4628 .ok()
4629 .and_then(|v| v.as_str().map(String::from))
4630 .unwrap_or_else(|| "skip".to_string());
4631 let scan_cfg = ScanConfig {
4632 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4633 path: project_path.to_string(),
4634 include_globs: run
4635 .effective_configuration
4636 .discovery
4637 .include_globs
4638 .join("\n"),
4639 exclude_globs: run
4640 .effective_configuration
4641 .discovery
4642 .exclude_globs
4643 .join("\n"),
4644 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
4645 mixed_line_policy: policy_str,
4646 python_docstrings_as_comments: run
4647 .effective_configuration
4648 .analysis
4649 .python_docstrings_as_comments,
4650 generated_file_detection: run
4651 .effective_configuration
4652 .analysis
4653 .generated_file_detection,
4654 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
4655 vendor_directory_detection: run
4656 .effective_configuration
4657 .analysis
4658 .vendor_directory_detection,
4659 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
4660 binary_file_behavior: behavior_str,
4661 output_dir: output_dir.unwrap_or("").to_string(),
4662 report_title: run.effective_configuration.reporting.report_title.clone(),
4663 };
4664 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
4665 let _ = std::fs::write(cfg_path, json);
4666 }
4667}
4668
4669#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
4671 mut config: AppConfig,
4672 git_repo: Option<String>,
4673 git_ref: Option<String>,
4674 clones_dir: PathBuf,
4675 cancel: Arc<std::sync::atomic::AtomicBool>,
4676 progress: Option<sloc_core::ProgressCounters>,
4677) -> Result<sloc_core::AnalysisRun> {
4678 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
4679 let dest = git_clone_dest(&repo, &clones_dir);
4680 sloc_git::clone_or_fetch(&repo, &dest)?;
4681 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
4682 sloc_git::create_worktree(&dest, &refname, &wt)?;
4683 config.discovery.root_paths = vec![wt.clone()];
4684 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
4685 let _ = sloc_git::destroy_worktree(&dest, &wt);
4686 let mut run = run?;
4687 if run.git_branch.is_none() {
4688 run.git_branch = Some(refname);
4689 }
4690 return Ok(run);
4691 }
4692 analyze(&config, "serve", Some(&cancel), progress.as_ref())
4693}
4694
4695fn derive_project_label(
4696 git_repo: Option<&str>,
4697 git_ref: Option<&str>,
4698 fallback_path: &str,
4699) -> String {
4700 match (
4701 git_repo.filter(|s| !s.is_empty()),
4702 git_ref.filter(|s| !s.is_empty()),
4703 ) {
4704 (Some(repo), Some(refname)) => {
4705 let repo_name = repo
4706 .trim_end_matches('/')
4707 .trim_end_matches(".git")
4708 .rsplit('/')
4709 .next()
4710 .unwrap_or("repo");
4711 sanitize_project_label(&format!("{repo_name}_{refname}"))
4712 }
4713 _ => sanitize_project_label(fallback_path),
4714 }
4715}
4716
4717fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
4718 let commit = commit_short.unwrap_or("").trim();
4719 if commit.is_empty() {
4720 project_label.to_string()
4721 } else {
4722 format!("{project_label}_{commit}")
4723 }
4724}
4725
4726#[derive(Serialize)]
4729#[serde(tag = "state", rename_all = "snake_case")]
4730enum AsyncRunStatusResponse {
4731 Running {
4732 elapsed_secs: u64,
4733 phase: String,
4734 files_done: u64,
4735 files_total: u64,
4736 },
4737 Complete {
4738 run_id: String,
4739 },
4740 Failed {
4741 message: String,
4742 },
4743 Cancelled,
4744}
4745
4746async fn async_run_status_handler(
4747 State(state): State<AppState>,
4748 AxumPath(wait_id): AxumPath<String>,
4749) -> Response {
4750 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4752 return error::bad_request("invalid wait_id");
4753 }
4754 let run_state = {
4755 let runs = state.async_runs.lock().await;
4756 runs.get(&wait_id).cloned()
4757 };
4758 match run_state {
4759 None => error::not_found("run not found"),
4760 Some(AsyncRunState::Running {
4761 started_at,
4762 phase,
4763 files_done,
4764 files_total,
4765 ..
4766 }) => {
4767 if started_at.elapsed() > std::time::Duration::from_hours(2) {
4769 let mut runs = state.async_runs.lock().await;
4770 runs.insert(
4771 wait_id,
4772 AsyncRunState::Failed {
4773 message: "Analysis timed out after 2 hours.".to_string(),
4774 },
4775 );
4776 drop(runs);
4777 return Json(AsyncRunStatusResponse::Failed {
4778 message: "Analysis timed out after 2 hours.".to_string(),
4779 })
4780 .into_response();
4781 }
4782 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
4783 Json(AsyncRunStatusResponse::Running {
4784 elapsed_secs: started_at.elapsed().as_secs(),
4785 phase: phase_str,
4786 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
4787 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
4788 })
4789 .into_response()
4790 }
4791 Some(AsyncRunState::Complete { run_id }) => {
4792 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
4793 }
4794 Some(AsyncRunState::Failed { message }) => {
4795 Json(AsyncRunStatusResponse::Failed { message }).into_response()
4796 }
4797 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
4798 }
4799}
4800
4801async fn cancel_run_handler(
4802 State(state): State<AppState>,
4803 AxumPath(wait_id): AxumPath<String>,
4804) -> Response {
4805 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
4806 return error::bad_request("invalid wait_id");
4807 }
4808 let mut runs = state.async_runs.lock().await;
4809 let resp = match runs.get(&wait_id) {
4810 Some(AsyncRunState::Running { cancel_token, .. }) => {
4811 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
4812 runs.insert(wait_id, AsyncRunState::Cancelled);
4813 StatusCode::OK.into_response()
4814 }
4815 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
4816 _ => error::not_found("run not found"),
4817 };
4818 drop(runs);
4819 resp
4820}
4821
4822async fn async_run_result_handler(
4823 State(state): State<AppState>,
4824 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4825 AxumPath(run_id): AxumPath<String>,
4826) -> Response {
4827 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
4828 return StatusCode::BAD_REQUEST.into_response();
4829 }
4830
4831 let artifacts = {
4832 let map = state.artifacts.lock().await;
4833 map.get(&run_id).cloned()
4834 };
4835 let artifacts = if let Some(a) = artifacts {
4836 a
4837 } else {
4838 let reg = state.registry.lock().await;
4839 if let Some(entry) = reg.find_by_run_id(&run_id) {
4840 recover_artifacts_from_registry(entry)
4841 } else {
4842 let html = ErrorTemplate {
4843 message: format!(
4844 "Report not found. Run ID {} is not in the scan history.",
4845 &run_id[..run_id.len().min(8)]
4846 ),
4847 last_report_url: Some("/view-reports".to_string()),
4848 last_report_label: Some("View Reports".to_string()),
4849 run_id: Some(run_id.clone()),
4850 error_code: Some(404),
4851 csp_nonce: csp_nonce.clone(),
4852 version: env!("CARGO_PKG_VERSION"),
4853 }
4854 .render()
4855 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
4856 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4857 }
4858 };
4859
4860 let json_path = if let Some(p) = &artifacts.json_path {
4861 p.clone()
4862 } else {
4863 let html = ErrorTemplate {
4864 message: "JSON result was not saved for this run.".to_string(),
4865 last_report_url: Some("/view-reports".to_string()),
4866 last_report_label: Some("View Reports".to_string()),
4867 run_id: Some(run_id.clone()),
4868 error_code: Some(404),
4869 csp_nonce: csp_nonce.clone(),
4870 version: env!("CARGO_PKG_VERSION"),
4871 }
4872 .render()
4873 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
4874 return (StatusCode::NOT_FOUND, Html(html)).into_response();
4875 };
4876
4877 let Ok(run) = read_json(&json_path) else {
4878 let folder_hint = json_path
4879 .parent()
4880 .map(|p| p.display().to_string())
4881 .unwrap_or_default();
4882 let redirect_url = format!("/runs/result/{run_id}");
4883 return missing_scan_relocate_response(
4884 &format!(
4885 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
4886 deleted. Browse to the folder containing your scan output to reconnect it.",
4887 json_path.display()
4888 ),
4889 &run_id,
4890 &folder_hint,
4891 &redirect_url,
4892 state.server_mode,
4893 &csp_nonce,
4894 );
4895 };
4896
4897 let confluence_configured = {
4898 let store = state.confluence.lock().await;
4899 store.is_configured()
4900 };
4901
4902 render_result_page(
4903 &run,
4904 &artifacts,
4905 &run_id,
4906 &csp_nonce,
4907 confluence_configured,
4908 state.server_mode,
4909 )
4910}
4911
4912#[allow(clippy::too_many_lines)]
4913#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
4916 run: &AnalysisRun,
4917 artifacts: &RunArtifacts,
4918 run_id: &str,
4919 csp_nonce: &str,
4920 confluence_configured: bool,
4921 server_mode: bool,
4922) -> Response {
4923 let ctx = &artifacts.result_context;
4924 let prev_entry = &ctx.prev_entry;
4925 let prev_scan_count = ctx.prev_scan_count;
4926 let project_path = &ctx.project_path;
4927
4928 let scan_delta = prev_entry.as_ref().and_then(|prev| {
4929 prev.json_path
4930 .as_ref()
4931 .and_then(|p| read_json(p).ok())
4932 .map(|prev_run| compute_delta(&prev_run, run))
4933 });
4934
4935 let files_analyzed = run.per_file_records.len() as u64;
4936 let files_skipped = run.skipped_file_records.len() as u64;
4937 let physical_lines = run
4938 .totals_by_language
4939 .iter()
4940 .map(|r| r.total_physical_lines)
4941 .sum::<u64>();
4942 let code_lines = run
4943 .totals_by_language
4944 .iter()
4945 .map(|r| r.code_lines)
4946 .sum::<u64>();
4947 let comment_lines = run
4948 .totals_by_language
4949 .iter()
4950 .map(|r| r.comment_lines)
4951 .sum::<u64>();
4952 let blank_lines = run
4953 .totals_by_language
4954 .iter()
4955 .map(|r| r.blank_lines)
4956 .sum::<u64>();
4957 let mixed_lines = run
4958 .totals_by_language
4959 .iter()
4960 .map(|r| r.mixed_lines_separate)
4961 .sum::<u64>();
4962 let functions = run
4963 .totals_by_language
4964 .iter()
4965 .map(|r| r.functions)
4966 .sum::<u64>();
4967 let classes = run
4968 .totals_by_language
4969 .iter()
4970 .map(|r| r.classes)
4971 .sum::<u64>();
4972 let variables = run
4973 .totals_by_language
4974 .iter()
4975 .map(|r| r.variables)
4976 .sum::<u64>();
4977 let imports = run
4978 .totals_by_language
4979 .iter()
4980 .map(|r| r.imports)
4981 .sum::<u64>();
4982
4983 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
4984 let prev_fa = prev_sum.map(|s| s.files_analyzed);
4985 let prev_fs = prev_sum.map(|s| s.files_skipped);
4986 let prev_pl = prev_sum.map(|s| s.total_physical_lines);
4987 let prev_cl = prev_sum.map(|s| s.code_lines);
4988 let prev_cml = prev_sum.map(|s| s.comment_lines);
4989 let prev_bl = prev_sum.map(|s| s.blank_lines);
4990 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
4991 let prev_fa_str = fmt_prev(prev_fa);
4992 let prev_fs_str = fmt_prev(prev_fs);
4993 let prev_pl_str = fmt_prev(prev_pl);
4994 let prev_cl_str = fmt_prev(prev_cl);
4995 let prev_cml_str = fmt_prev(prev_cml);
4996 let prev_bl_str = fmt_prev(prev_bl);
4997 let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
4998 let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
4999 let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
5000 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
5001 let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
5002 let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
5003 let delta_fa_class = delta_fa_class.to_string();
5004 let delta_fs_class = delta_fs_class.to_string();
5005 let delta_pl_class = delta_pl_class.to_string();
5006 let delta_cl_class = delta_cl_class.to_string();
5007 let delta_cml_class = delta_cml_class.to_string();
5008 let delta_bl_class = delta_bl_class.to_string();
5009
5010 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
5011 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
5012 let (delta_lines_net_str, delta_lines_net_class) =
5013 match (delta_lines_added, delta_lines_removed) {
5014 (Some(a), Some(r)) => {
5015 let net = a - r;
5016 (fmt_delta(net), delta_class(net).to_string())
5017 }
5018 _ => ("—".to_string(), "na".to_string()),
5019 };
5020
5021 let run_dir = artifacts.output_dir.clone();
5022 let git_branch = run.git_branch.clone();
5023 let git_commit = run.git_commit_short.clone();
5024 let git_commit_long = run.git_commit_long.clone();
5025 let git_author = run.git_commit_author.clone();
5026 let git_commit_url = run
5027 .git_remote_url
5028 .as_deref()
5029 .zip(run.git_commit_long.as_deref())
5030 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
5031 let git_branch_url = run
5032 .git_remote_url
5033 .as_deref()
5034 .zip(run.git_branch.as_deref())
5035 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
5036 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
5037 format!(
5038 "{} / {}",
5039 run.environment.initiator_username, run.environment.initiator_hostname
5040 )
5041 });
5042 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5043 let os_display = format!(
5044 "{} / {}",
5045 run.environment.operating_system, run.environment.architecture
5046 );
5047 let test_count = run.summary_totals.test_count;
5048
5049 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5051 let lsloc = run.summary_totals.lsloc;
5052 let uloc = run.uloc;
5053 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5054 let duplicate_group_count = run.duplicate_groups.len();
5055
5056 let ctx = &artifacts.result_context;
5058 let (
5059 has_cocomo,
5060 cocomo_effort_str,
5061 cocomo_duration_str,
5062 cocomo_staff_str,
5063 cocomo_ksloc_str,
5064 cocomo_mode_label,
5065 cocomo_mode_tooltip,
5066 ) = {
5067 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5068 let mode_str = ctx.cocomo_mode.as_str();
5069 let (a, b, c, d, label, tooltip): (f64, f64, f64, f64, &str, &str) = match mode_str {
5070 "semi_detached" => (3.0, 1.12, 2.5, 0.35, "Semi-detached",
5071 "Semi-detached: A mixed team with varying experience tackling a project with \
5072 moderate novelty and some rigid constraints. Typical for compilers, transaction \
5073 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."),
5074 "embedded" => (3.6, 1.20, 2.5, 0.32, "Embedded",
5075 "Embedded: Tight hardware, software, or operational constraints requiring \
5076 significant innovation and deep integration work. Typical for real-time control \
5077 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."),
5078 _ => (2.4, 1.05, 2.5, 0.38, "Organic",
5079 "Organic: A small team working on a well-understood project in a familiar \
5080 environment with minimal external constraints. Suited for internal tools, \
5081 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."),
5082 };
5083 let effort = a * ksloc.powf(b);
5084 let duration = c * effort.powf(d);
5085 let staff = if duration > 0.0 {
5086 effort / duration
5087 } else {
5088 0.0
5089 };
5090 if run.summary_totals.code_lines > 0 {
5091 (
5092 true,
5093 format!("{:.2}", (effort * 100.0).round() / 100.0),
5094 format!("{:.2}", (duration * 100.0).round() / 100.0),
5095 format!("{:.2}", (staff * 100.0).round() / 100.0),
5096 format!("{:.2}", (ksloc * 100.0).round() / 100.0),
5097 label.to_string(),
5098 tooltip.to_string(),
5099 )
5100 } else {
5101 (
5102 false,
5103 String::new(),
5104 String::new(),
5105 String::new(),
5106 String::new(),
5107 label.to_string(),
5108 tooltip.to_string(),
5109 )
5110 }
5111 };
5112 let complexity_alert = ctx.complexity_alert;
5113
5114 let template = ResultTemplate {
5115 version: env!("CARGO_PKG_VERSION"),
5116 report_title: run.effective_configuration.reporting.report_title.clone(),
5117 project_path: project_path.clone(),
5118 output_dir: display_path(&artifacts.output_dir),
5119 run_id: run_id.to_owned(),
5120 run_id_short: run_id
5121 .split('-')
5122 .next_back()
5123 .unwrap_or(run_id)
5124 .chars()
5125 .take(7)
5126 .collect(),
5127 files_analyzed,
5128 files_skipped,
5129 physical_lines,
5130 code_lines,
5131 comment_lines,
5132 blank_lines,
5133 mixed_lines,
5134 functions,
5135 classes,
5136 variables,
5137 imports,
5138 html_url: artifacts
5139 .html_path
5140 .as_ref()
5141 .map(|_| format!("/runs/html/{run_id}")),
5142 pdf_url: artifacts
5143 .pdf_path
5144 .as_ref()
5145 .map(|_| format!("/runs/pdf/{run_id}")),
5146 json_url: artifacts
5147 .json_path
5148 .as_ref()
5149 .map(|_| format!("/runs/json/{run_id}")),
5150 html_download_url: artifacts
5151 .html_path
5152 .as_ref()
5153 .map(|_| format!("/runs/html/{run_id}?download=1")),
5154 pdf_download_url: artifacts
5155 .pdf_path
5156 .as_ref()
5157 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
5158 json_download_url: artifacts
5159 .json_path
5160 .as_ref()
5161 .map(|_| format!("/runs/json/{run_id}?download=1")),
5162 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
5163 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
5164 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
5165 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
5166 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
5167 prev_fa_str,
5168 prev_fs_str,
5169 prev_pl_str,
5170 prev_cl_str,
5171 prev_cml_str,
5172 prev_bl_str,
5173 delta_fa_str,
5174 delta_fa_class,
5175 delta_fs_str,
5176 delta_fs_class,
5177 delta_pl_str,
5178 delta_pl_class,
5179 delta_cl_str,
5180 delta_cl_class,
5181 delta_cml_str,
5182 delta_cml_class,
5183 delta_bl_str,
5184 delta_bl_class,
5185 delta_lines_added,
5186 delta_lines_removed,
5187 delta_lines_net_str,
5188 delta_lines_net_class,
5189 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
5190 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
5191 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
5192 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
5193 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
5194 d.file_deltas
5195 .iter()
5196 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5197 .map(|f| {
5198 #[allow(clippy::cast_sign_loss)]
5199 let n = f.current_code as u64;
5200 n
5201 })
5202 .sum()
5203 }),
5204 git_branch,
5205 git_branch_url,
5206 git_commit,
5207 git_commit_long,
5208 git_author,
5209 git_commit_url,
5210 scan_performed_by,
5211 scan_time_display,
5212 os_display,
5213 test_count,
5214 current_scan_number: prev_scan_count + 1,
5215 prev_scan_count,
5216 submodule_rows: run
5217 .submodule_summaries
5218 .iter()
5219 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
5220 .collect(),
5221 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
5222 scan_config_url: format!("/runs/scan-config/{run_id}"),
5223 lang_chart_json: {
5224 let mut langs: Vec<&sloc_core::LanguageSummary> =
5225 run.totals_by_language.iter().collect();
5226 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5227 let entries: Vec<String> = langs
5228 .into_iter()
5229 .take(12)
5230 .map(|l| {
5231 let name = l
5232 .language
5233 .display_name()
5234 .replace('\\', "\\\\")
5235 .replace('"', "\\\"");
5236 format!(
5237 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5238 name,
5239 l.code_lines,
5240 l.comment_lines,
5241 l.blank_lines,
5242 l.total_physical_lines,
5243 l.functions,
5244 l.classes,
5245 l.variables,
5246 l.imports,
5247 l.files,
5248 )
5249 })
5250 .collect();
5251 format!("[{}]", entries.join(","))
5252 },
5253 scatter_chart_json: {
5254 let entries: Vec<String> = run
5255 .totals_by_language
5256 .iter()
5257 .map(|l| {
5258 let name = l
5259 .language
5260 .display_name()
5261 .replace('\\', "\\\\")
5262 .replace('"', "\\\"");
5263 format!(
5264 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5265 name, l.files, l.code_lines, l.total_physical_lines,
5266 )
5267 })
5268 .collect();
5269 format!("[{}]", entries.join(","))
5270 },
5271 semantic_chart_json: {
5272 let entries: Vec<String> = run
5273 .totals_by_language
5274 .iter()
5275 .filter(|l| {
5276 l.functions > 0
5277 || l.classes > 0
5278 || l.variables > 0
5279 || l.imports > 0
5280 || l.test_count > 0
5281 })
5282 .map(|l| {
5283 let name = l
5284 .language
5285 .display_name()
5286 .replace('\\', "\\\\")
5287 .replace('"', "\\\"");
5288 format!(
5289 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5290 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5291 )
5292 })
5293 .collect();
5294 format!("[{}]", entries.join(","))
5295 },
5296 submodule_chart_json: {
5297 let entries: Vec<String> = run
5298 .submodule_summaries
5299 .iter()
5300 .map(|s| {
5301 let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
5302 format!(
5303 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5304 name,
5305 s.code_lines,
5306 s.comment_lines,
5307 s.blank_lines,
5308 s.total_physical_lines,
5309 s.files_analyzed,
5310 )
5311 })
5312 .collect();
5313 format!("[{}]", entries.join(","))
5314 },
5315 has_submodule_data: !run.submodule_summaries.is_empty(),
5316 has_semantic_data: run
5317 .totals_by_language
5318 .iter()
5319 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
5320 csp_nonce: csp_nonce.to_owned(),
5321 confluence_configured,
5322 server_mode,
5323 report_header_footer: run
5324 .effective_configuration
5325 .reporting
5326 .report_header_footer
5327 .clone(),
5328 is_offline: false,
5329 cyclomatic_complexity,
5330 lsloc,
5331 uloc,
5332 dryness_pct_str,
5333 duplicate_group_count,
5334 has_cocomo,
5335 cocomo_effort_str,
5336 cocomo_duration_str,
5337 cocomo_staff_str,
5338 cocomo_ksloc_str,
5339 cocomo_mode_label,
5340 cocomo_mode_tooltip,
5341 complexity_alert,
5342 };
5343
5344 Html(
5345 template
5346 .render()
5347 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
5348 )
5349 .into_response()
5350}
5351
5352fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
5353 let slug: String = report_title
5354 .chars()
5355 .map(|c| {
5356 if c.is_alphanumeric() || c == '-' {
5357 c.to_ascii_lowercase()
5358 } else {
5359 '_'
5360 }
5361 })
5362 .collect::<String>()
5363 .split('_')
5364 .filter(|s| !s.is_empty())
5365 .collect::<Vec<_>>()
5366 .join("_");
5367
5368 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
5369
5370 if slug.is_empty() {
5371 format!("report_{short_id}.pdf")
5372 } else {
5373 format!("{slug}_{short_id}.pdf")
5374 }
5375}
5376
5377#[derive(Serialize)]
5378struct PdfStatusResponse {
5379 ready: bool,
5380}
5381
5382async fn pdf_status_handler(
5385 State(state): State<AppState>,
5386 AxumPath(run_id): AxumPath<String>,
5387) -> Response {
5388 let pdf_path = {
5389 let registry = state.artifacts.lock().await;
5390 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
5391 };
5392 let pdf_path = if pdf_path.is_some() {
5393 pdf_path
5394 } else {
5395 let reg = state.registry.lock().await;
5396 reg.find_by_run_id(&run_id)
5397 .map(recover_artifacts_from_registry)
5398 .and_then(|a| a.pdf_path)
5399 };
5400 let ready = pdf_path.is_some_and(|p| p.exists());
5401 Json(PdfStatusResponse { ready }).into_response()
5402}
5403
5404async fn download_bundle_handler(
5410 State(state): State<AppState>,
5411 AxumPath(run_id): AxumPath<String>,
5412) -> Response {
5413 let output_dir = {
5415 let cache = state.artifacts.lock().await;
5416 cache.get(&run_id).map(|a| a.output_dir.clone())
5417 };
5418 let output_dir = if let Some(d) = output_dir {
5419 d
5420 } else {
5421 let reg = state.registry.lock().await;
5422 match reg.find_by_run_id(&run_id) {
5423 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
5424 None => {
5425 return (
5426 StatusCode::NOT_FOUND,
5427 Json(serde_json::json!({"error": "Run not found"})),
5428 )
5429 .into_response();
5430 }
5431 }
5432 };
5433
5434 if !output_dir.exists() {
5435 return (
5436 StatusCode::NOT_FOUND,
5437 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
5438 )
5439 .into_response();
5440 }
5441
5442 let run_id_clone = run_id.clone();
5444 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
5445 use flate2::{write::GzEncoder, Compression};
5446 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
5447 {
5448 let mut tar = tar::Builder::new(&mut enc);
5449 tar.follow_symlinks(false);
5450 if let Ok(entries) = std::fs::read_dir(&output_dir) {
5453 for entry in entries.filter_map(Result::ok) {
5454 let p = entry.path();
5455 if p.is_file() {
5456 let name = p.file_name().unwrap_or_default().to_string_lossy();
5457 let archive_path = format!("{run_id_clone}/{name}");
5458 tar.append_path_with_name(&p, &archive_path)?;
5459 }
5460 }
5461 }
5462 tar.finish()?;
5463 }
5464 Ok(enc.finish()?)
5465 })
5466 .await;
5467
5468 match archive_result {
5469 Ok(Ok(bytes)) => {
5470 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
5471 axum::response::Response::builder()
5472 .status(StatusCode::OK)
5473 .header("Content-Type", "application/gzip")
5474 .header(
5475 "Content-Disposition",
5476 format!("attachment; filename=\"{filename}\""),
5477 )
5478 .header("Content-Length", bytes.len().to_string())
5479 .body(axum::body::Body::from(bytes))
5480 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
5481 }
5482 Ok(Err(e)) => (
5483 StatusCode::INTERNAL_SERVER_ERROR,
5484 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
5485 )
5486 .into_response(),
5487 Err(e) => (
5488 StatusCode::INTERNAL_SERVER_ERROR,
5489 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
5490 )
5491 .into_response(),
5492 }
5493}
5494
5495async fn delete_run_handler(
5500 State(state): State<AppState>,
5501 AxumPath(run_id): AxumPath<String>,
5502) -> Response {
5503 let output_dir = {
5505 let mut cache = state.artifacts.lock().await;
5506 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
5507 cache.remove(&run_id);
5508 dir
5509 };
5510 let output_dir = if let Some(d) = output_dir {
5511 d
5512 } else {
5513 let reg = state.registry.lock().await;
5514 reg.find_by_run_id(&run_id)
5515 .map(|e| recover_artifacts_from_registry(e).output_dir)
5516 .unwrap_or_default()
5517 };
5518
5519 {
5521 let mut reg = state.registry.lock().await;
5522 reg.entries.retain(|e| e.run_id != run_id);
5523 let _ = reg.save(&state.registry_path);
5524 }
5525
5526 if output_dir.exists() {
5529 match tokio::fs::remove_dir_all(&output_dir).await {
5530 Ok(()) => {}
5531 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
5532 Err(e) => {
5533 return (
5534 StatusCode::INTERNAL_SERVER_ERROR,
5535 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
5536 )
5537 .into_response();
5538 }
5539 }
5540 }
5541
5542 StatusCode::NO_CONTENT.into_response()
5543}
5544
5545async fn cleanup_runs_handler(
5550 State(state): State<AppState>,
5551 Json(body): Json<serde_json::Value>,
5552) -> Response {
5553 let days = body
5554 .get("older_than_days")
5555 .and_then(serde_json::Value::as_u64)
5556 .unwrap_or(30)
5557 .max(1);
5558
5559 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
5560
5561 let expired: Vec<(String, PathBuf)> = {
5563 let reg = state.registry.lock().await;
5564 reg.entries
5565 .iter()
5566 .filter(|e| e.timestamp_utc < cutoff)
5567 .map(|e| {
5568 let arts = recover_artifacts_from_registry(e);
5569 (e.run_id.clone(), arts.output_dir)
5570 })
5571 .collect()
5572 };
5573
5574 let mut deleted = 0usize;
5575 for (run_id, output_dir) in &expired {
5576 state.artifacts.lock().await.remove(run_id);
5578 if output_dir.exists() {
5580 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
5581 eprintln!(
5582 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
5583 output_dir.display()
5584 );
5585 continue;
5586 }
5587 }
5588 deleted += 1;
5589 }
5590
5591 let expired_ids: std::collections::HashSet<&str> =
5593 expired.iter().map(|(id, _)| id.as_str()).collect();
5594 {
5595 let mut reg = state.registry.lock().await;
5596 reg.entries
5597 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
5598 let _ = reg.save(&state.registry_path);
5599 }
5600
5601 Json(serde_json::json!({ "deleted": deleted })).into_response()
5602}
5603
5604fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
5607 tokio::spawn(async move {
5608 loop {
5609 let interval_secs = {
5610 let store = state.cleanup_policy.lock().await;
5611 match &store.policy {
5612 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
5613 _ => break,
5614 }
5615 };
5616 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
5617 let n = run_auto_cleanup(&state).await;
5618 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
5619 }
5620 })
5621}
5622
5623fn collect_runs_to_delete(
5624 reg: &ScanRegistry,
5625 max_age_days: Option<u32>,
5626 max_run_count: Option<u32>,
5627) -> std::collections::HashSet<String> {
5628 let mut to_delete = std::collections::HashSet::new();
5629 if let Some(days) = max_age_days {
5630 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
5631 for e in ®.entries {
5632 if e.timestamp_utc < cutoff {
5633 to_delete.insert(e.run_id.clone());
5634 }
5635 }
5636 }
5637 if let Some(max_count) = max_run_count {
5638 for e in reg.entries.iter().skip(max_count as usize) {
5640 to_delete.insert(e.run_id.clone());
5641 }
5642 }
5643 to_delete
5644}
5645
5646async fn delete_run_artifacts(state: &AppState, run_id: &str) {
5647 let output_dir = {
5648 let mut cache = state.artifacts.lock().await;
5649 let d = cache.get(run_id).map(|a| a.output_dir.clone());
5650 cache.remove(run_id);
5651 d
5652 };
5653 let output_dir = if let Some(d) = output_dir {
5654 d
5655 } else {
5656 let reg = state.registry.lock().await;
5657 reg.find_by_run_id(run_id)
5658 .map(|e| recover_artifacts_from_registry(e).output_dir)
5659 .unwrap_or_default()
5660 };
5661 if output_dir.exists() {
5662 let _ = tokio::fs::remove_dir_all(&output_dir).await;
5663 }
5664}
5665
5666async fn run_auto_cleanup(state: &AppState) -> u32 {
5670 let (max_age_days, max_run_count) = {
5671 let store = state.cleanup_policy.lock().await;
5672 match &store.policy {
5673 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
5674 _ => return 0,
5675 }
5676 };
5677
5678 let to_delete = {
5679 let reg = state.registry.lock().await;
5680 collect_runs_to_delete(®, max_age_days, max_run_count)
5681 };
5682
5683 for run_id in &to_delete {
5684 delete_run_artifacts(state, run_id).await;
5685 }
5686
5687 if !to_delete.is_empty() {
5689 let mut reg = state.registry.lock().await;
5690 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
5691 let _ = reg.save(&state.registry_path);
5692 }
5693
5694 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
5695 {
5696 let mut store = state.cleanup_policy.lock().await;
5697 store.last_run_at = Some(chrono::Utc::now());
5698 store.last_run_deleted = Some(deleted);
5699 let _ = store.save(&state.cleanup_policy_path);
5700 }
5701 deleted
5702}
5703
5704async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
5708 let store = state.cleanup_policy.lock().await;
5709 Json(serde_json::json!({
5710 "policy": store.policy,
5711 "last_run_at": store.last_run_at,
5712 "last_run_deleted": store.last_run_deleted,
5713 }))
5714 .into_response()
5715}
5716
5717async fn api_save_cleanup_policy(
5719 State(state): State<AppState>,
5720 Json(body): Json<CleanupPolicy>,
5721) -> Response {
5722 {
5724 let mut handle = state.cleanup_task_handle.lock().await;
5725 if let Some(h) = handle.take() {
5726 h.abort();
5727 }
5728 }
5729 {
5730 let mut store = state.cleanup_policy.lock().await;
5731 store.policy = Some(body.clone());
5732 if let Err(e) = store.save(&state.cleanup_policy_path) {
5733 return (
5734 StatusCode::INTERNAL_SERVER_ERROR,
5735 Json(serde_json::json!({"error": e.to_string()})),
5736 )
5737 .into_response();
5738 }
5739 }
5740 if body.enabled {
5741 let handle = spawn_cleanup_policy_task(state.clone());
5742 *state.cleanup_task_handle.lock().await = Some(handle);
5743 }
5744 StatusCode::NO_CONTENT.into_response()
5745}
5746
5747async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
5749 let deleted = run_auto_cleanup(&state).await;
5750 Json(serde_json::json!({ "deleted": deleted })).into_response()
5751}
5752
5753async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
5755 {
5756 let mut handle = state.cleanup_task_handle.lock().await;
5757 if let Some(h) = handle.take() {
5758 h.abort();
5759 }
5760 }
5761 {
5762 let mut store = state.cleanup_policy.lock().await;
5763 store.policy = None;
5764 let _ = store.save(&state.cleanup_policy_path);
5765 }
5766 StatusCode::NO_CONTENT.into_response()
5767}
5768
5769fn swap_inline_chart_js_for_static(html: String) -> String {
5775 let Some(head_end) = html.find("</head>") else {
5776 return html;
5777 };
5778 let Some(script_start) = html[..head_end].rfind("<script") else {
5779 return html;
5780 };
5781 let Some(close_offset) = html[script_start..].find("</script>") else {
5782 return html;
5783 };
5784 let block_end = script_start + close_offset + "</script>".len();
5785 format!(
5786 "{}<script src=\"/static/chart-report.js\"></script>{}",
5787 &html[..script_start],
5788 &html[block_end..]
5789 )
5790}
5791
5792fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
5794 let Some(start) = html.find("nonce=\"") else {
5796 return html
5800 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
5801 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
5802 };
5803 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
5805 return html.to_owned();
5806 };
5807 let old_nonce = &html[value_start..value_start + end_offset];
5808 html.replace(
5809 &format!("nonce=\"{old_nonce}\""),
5810 &format!("nonce=\"{new_nonce}\""),
5811 )
5812}
5813
5814fn serve_html_artifact(
5815 path: &Path,
5816 wants_download: bool,
5817 csp_nonce: &str,
5818 run_id: &str,
5819 server_mode: bool,
5820) -> Response {
5821 match fs::read_to_string(path) {
5822 Ok(raw) => {
5823 let content = patch_html_nonce(&raw, csp_nonce);
5825 if wants_download {
5826 (
5828 [
5829 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
5830 (
5831 header::CONTENT_DISPOSITION,
5832 "attachment; filename=report.html",
5833 ),
5834 ],
5835 content,
5836 )
5837 .into_response()
5838 } else {
5839 Html(swap_inline_chart_js_for_static(content)).into_response()
5842 }
5843 }
5844 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
5845 let filename = path.file_name().map_or_else(
5846 || "report.html".to_string(),
5847 |n| n.to_string_lossy().into_owned(),
5848 );
5849 let html = LocateFileTemplate {
5850 run_id: run_id.to_owned(),
5851 artifact_type: "html".to_string(),
5852 expected_filename: filename,
5853 server_mode,
5854 csp_nonce: csp_nonce.to_owned(),
5855 version: env!("CARGO_PKG_VERSION"),
5856 }
5857 .render()
5858 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5859 (StatusCode::NOT_FOUND, Html(html)).into_response()
5860 }
5861 Err(err) => {
5862 let filename = path.file_name().map_or_else(
5863 || "report.html".to_string(),
5864 |n| n.to_string_lossy().into_owned(),
5865 );
5866 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
5867 let html = ErrorTemplate {
5868 message: msg,
5869 last_report_url: Some("/view-reports".to_string()),
5870 last_report_label: Some("View Reports".to_string()),
5871 run_id: None,
5872 error_code: Some(404),
5873 csp_nonce: csp_nonce.to_owned(),
5874 version: env!("CARGO_PKG_VERSION"),
5875 }
5876 .render()
5877 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5878 (StatusCode::NOT_FOUND, Html(html)).into_response()
5879 }
5880 }
5881}
5882
5883fn serve_pdf_artifact(
5885 path: &Path,
5886 report_title: &str,
5887 run_id: &str,
5888 wants_download: bool,
5889 csp_nonce: &str,
5890) -> Response {
5891 match fs::read(path) {
5892 Ok(bytes) => {
5893 let filename = build_pdf_filename(report_title, run_id);
5894 let disposition = if wants_download {
5895 format!("attachment; filename=\"{filename}\"")
5896 } else {
5897 format!("inline; filename=\"{filename}\"")
5898 };
5899 (
5900 [
5901 (header::CONTENT_TYPE, "application/pdf".to_string()),
5902 (header::CONTENT_DISPOSITION, disposition),
5903 ],
5904 bytes,
5905 )
5906 .into_response()
5907 }
5908 Err(err) => {
5909 let filename = path.file_name().map_or_else(
5910 || "report.pdf".to_string(),
5911 |n| n.to_string_lossy().into_owned(),
5912 );
5913 let msg = format!(
5914 "PDF report '{filename}' could not be read.\n\n\
5915 Error: {err}\n\n\
5916 If you moved or renamed the output folder, the stored path is now stale. \
5917 Use 'Open PDF folder' from the results page to browse the output directory."
5918 );
5919 let html = ErrorTemplate {
5920 message: msg,
5921 last_report_url: Some("/view-reports".to_string()),
5922 last_report_label: Some("View Reports".to_string()),
5923 run_id: Some(run_id.to_owned()),
5924 error_code: Some(404),
5925 csp_nonce: csp_nonce.to_owned(),
5926 version: env!("CARGO_PKG_VERSION"),
5927 }
5928 .render()
5929 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5930 (StatusCode::NOT_FOUND, Html(html)).into_response()
5931 }
5932 }
5933}
5934
5935fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
5937 match fs::read(path) {
5938 Ok(bytes) => {
5939 if wants_download {
5940 (
5941 [
5942 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
5943 (
5944 header::CONTENT_DISPOSITION,
5945 "attachment; filename=result.json",
5946 ),
5947 ],
5948 bytes,
5949 )
5950 .into_response()
5951 } else {
5952 (
5953 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
5954 bytes,
5955 )
5956 .into_response()
5957 }
5958 }
5959 Err(err) => {
5960 let filename = path.file_name().map_or_else(
5961 || "result.json".to_string(),
5962 |n| n.to_string_lossy().into_owned(),
5963 );
5964 let msg = format!(
5965 "JSON result '{filename}' could not be read.\n\n\
5966 Error: {err}\n\n\
5967 If you moved or renamed the output folder, the stored path is now stale. \
5968 Use 'Open JSON folder' from the results page to browse the output directory."
5969 );
5970 let html = ErrorTemplate {
5971 message: msg,
5972 last_report_url: Some("/view-reports".to_string()),
5973 last_report_label: Some("View Reports".to_string()),
5974 run_id: None,
5975 error_code: Some(404),
5976 csp_nonce: csp_nonce.to_owned(),
5977 version: env!("CARGO_PKG_VERSION"),
5978 }
5979 .render()
5980 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
5981 (StatusCode::NOT_FOUND, Html(html)).into_response()
5982 }
5983 }
5984}
5985
5986fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
5988 let output_dir = entry
5991 .html_path
5992 .as_ref()
5993 .or(entry.json_path.as_ref())
5994 .or(entry.pdf_path.as_ref())
5995 .or(entry.csv_path.as_ref())
5996 .or(entry.xlsx_path.as_ref())
5997 .and_then(|p| {
5998 let parent = p.parent()?;
5999 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6000 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6002 parent.parent().map(PathBuf::from)
6003 } else {
6004 Some(parent.to_path_buf())
6005 }
6006 })
6007 .unwrap_or_default();
6008 let pdf_path = entry.pdf_path.clone().or_else(|| {
6011 let candidate = output_dir.join("report.pdf");
6012 candidate.exists().then_some(candidate)
6013 });
6014 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6018 for dir in &[output_dir.join("excel"), output_dir.clone()] {
6020 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6021 entries
6022 .filter_map(std::result::Result::ok)
6023 .find(|e| {
6024 let n = e.file_name();
6025 let n = n.to_string_lossy();
6026 n.starts_with("report_") && n.ends_with(ext)
6027 })
6028 .map(|e| e.path())
6029 }) {
6030 return Some(p);
6031 }
6032 }
6033 None
6034 };
6035
6036 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6037 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6038 RunArtifacts {
6039 output_dir: output_dir.clone(),
6040 html_path: entry.html_path.clone(),
6041 pdf_path,
6042 json_path: entry.json_path.clone(),
6043 csv_path,
6044 xlsx_path,
6045 scan_config_path: find_scan_config_in_dir(&output_dir),
6046 report_title: entry.project_label.clone(),
6047 result_context: RunResultContext::default(),
6048 }
6049}
6050
6051#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
6053 state: &AppState,
6054 run_id: &str,
6055 csp_nonce: &str,
6056) -> Result<RunArtifacts, Response> {
6057 let cached = state.artifacts.lock().await.get(run_id).cloned();
6058 if let Some(a) = cached {
6059 return Ok(a);
6060 }
6061 let reg = state.registry.lock().await;
6062 if let Some(entry) = reg.find_by_run_id(run_id) {
6063 return Ok(recover_artifacts_from_registry(entry));
6064 }
6065 drop(reg);
6066 let short_id = &run_id[..run_id.len().min(8)];
6067 let hint = if matches!(
6068 run_id,
6069 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6070 ) {
6071 format!(
6072 " The URL format appears to be reversed — \
6073 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6074 Use the View Reports page to navigate to your scan."
6075 )
6076 } else {
6077 " The report may have been deleted or the report directory moved. \
6078 Use View Reports to browse your scan history."
6079 .to_string()
6080 };
6081 let error_html = ErrorTemplate {
6082 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6083 last_report_url: Some("/view-reports".to_string()),
6084 last_report_label: Some("View Reports".to_string()),
6085 run_id: None,
6086 error_code: Some(404),
6087 csp_nonce: csp_nonce.to_owned(),
6088 version: env!("CARGO_PKG_VERSION"),
6089 }
6090 .render()
6091 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6092 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6093}
6094
6095async fn resolve_or_queue_pdf(
6100 state: &AppState,
6101 pdf_path: Option<PathBuf>,
6102 json_path: Option<PathBuf>,
6103 output_dir: PathBuf,
6104 run_id: &str,
6105 report_title: &str,
6106 csp_nonce: &str,
6107) -> Result<PathBuf, Response> {
6108 if let Some(p) = pdf_path {
6109 return Ok(p);
6110 }
6111 let Some(json_src) = json_path.filter(|p| p.exists()) else {
6112 let msg = "PDF report was not generated for this run. \
6113 Re-run the analysis with PDF output enabled."
6114 .to_string();
6115 let html = ErrorTemplate {
6116 message: msg,
6117 last_report_url: Some(format!("/runs/html/{run_id}")),
6118 last_report_label: Some("View HTML Report".to_string()),
6119 run_id: Some(run_id.to_string()),
6120 error_code: Some(404),
6121 csp_nonce: csp_nonce.to_string(),
6122 version: env!("CARGO_PKG_VERSION"),
6123 }
6124 .render()
6125 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6126 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6127 };
6128 let pdf_filename = build_pdf_filename(report_title, run_id);
6129 let pdf_dest = output_dir.join(&pdf_filename);
6130 if !pdf_dest.exists() {
6131 {
6133 let mut map = state.artifacts.lock().await;
6134 if let Some(entry) = map.get_mut(run_id) {
6135 entry.pdf_path = Some(pdf_dest.clone());
6136 }
6137 }
6138 {
6139 let mut reg = state.registry.lock().await;
6140 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6141 e.pdf_path = Some(pdf_dest.clone());
6142 }
6143 let _ = reg.save(&state.registry_path);
6144 }
6145 spawn_native_pdf_background(
6146 json_src,
6147 pdf_dest.clone(),
6148 run_id.to_string(),
6149 state.artifacts.clone(),
6150 );
6151 }
6152 Ok(pdf_dest)
6153}
6154
6155fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6157 let html = format!(
6158 "<!doctype html><html lang=\"en\"><head>\
6159 <meta charset=utf-8>\
6160 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6161 <meta http-equiv=\"refresh\" content=\"5\">\
6162 <title>OxideSLOC | Generating PDF\u{2026}</title>\
6163 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6164 <style nonce=\"{csp_nonce}\">\
6165 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6166 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6167 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6168 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6169 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6170 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6171 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6172 background:var(--bg);color:var(--text);}}\
6173 .top-nav{{position:sticky;top:0;z-index:30;\
6174 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6175 border-bottom:1px solid rgba(255,255,255,0.12);\
6176 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6177 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6178 min-height:56px;display:flex;align-items:center;gap:14px;}}\
6179 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6180 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6181 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6182 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6183 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6184 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6185 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6186 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6187 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6188 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6189 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6190 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6191 justify-content:center;min-height:38px;border-radius:999px;\
6192 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6193 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6194 .theme-toggle .icon-sun{{display:none;}}\
6195 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6196 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6197 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6198 display:flex;align-items:center;justify-content:center;\
6199 min-height:calc(100vh - 56px);}}\
6200 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6201 .panel{{background:var(--surface);border:1px solid var(--line);\
6202 border-radius:var(--radius);box-shadow:var(--shadow);\
6203 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6204 .spin-ring{{width:56px;height:56px;border-radius:50%;\
6205 border:5px solid var(--line);border-top-color:var(--oxide-2);\
6206 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6207 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6208 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6209 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6210 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6211 min-height:42px;padding:0 20px;border-radius:14px;\
6212 border:1px solid var(--line-strong);text-decoration:none;\
6213 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6214 .back-link:hover{{background:var(--line);}}\
6215 </style></head>\
6216 <body>\
6217 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6218 <a class=\"brand\" href=\"/\">\
6219 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6220 <div class=\"brand-copy\">\
6221 <div class=\"brand-title\">OxideSLOC</div>\
6222 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6223 </div>\
6224 </a>\
6225 <div class=\"nav-right\">\
6226 <a class=\"nav-pill\" href=\"/\">Home</a>\
6227 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6228 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6229 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6230 <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>\
6231 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6232 <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>\
6233 </button>\
6234 </div>\
6235 </div></div>\
6236 <div class=\"page\"><div class=\"panel\">\
6237 <div class=\"spin-ring\"></div>\
6238 <h1>Generating PDF\u{2026}</h1>\
6239 <p>The PDF is being generated from the scan results.<br>\
6240 This page refreshes automatically \u{2014} usually a few seconds.</p>\
6241 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
6242 </div></div>\
6243 <script nonce=\"{csp_nonce}\">\
6244 (function(){{\
6245 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
6246 if(s===\"dark\")b.classList.add(\"dark-theme\");\
6247 var t=document.getElementById(\"theme-toggle\");\
6248 if(t)t.addEventListener(\"click\",function(){{\
6249 var d=b.classList.toggle(\"dark-theme\");\
6250 localStorage.setItem(k,d?\"dark\":\"light\");\
6251 }});\
6252 }})();\
6253 </script>\
6254 </body></html>"
6255 );
6256 Html(html).into_response()
6257}
6258
6259fn render_error_artifact_html(
6261 message: String,
6262 last_report_url: Option<String>,
6263 last_report_label: Option<String>,
6264 run_id: Option<String>,
6265 error_code: Option<u16>,
6266 csp_nonce: &str,
6267) -> String {
6268 ErrorTemplate {
6269 message,
6270 last_report_url,
6271 last_report_label,
6272 run_id,
6273 error_code,
6274 csp_nonce: csp_nonce.to_owned(),
6275 version: env!("CARGO_PKG_VERSION"),
6276 }
6277 .render()
6278 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
6279}
6280
6281fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
6283 fs::read(path).map_or_else(
6284 |_| StatusCode::NOT_FOUND.into_response(),
6285 |bytes| {
6286 let filename = path.file_name().map_or_else(
6287 || fallback_filename.to_string(),
6288 |n| n.to_string_lossy().into_owned(),
6289 );
6290 (
6291 [
6292 (header::CONTENT_TYPE, content_type.to_string()),
6293 (
6294 header::CONTENT_DISPOSITION,
6295 format!("attachment; filename=\"{filename}\""),
6296 ),
6297 ],
6298 bytes,
6299 )
6300 .into_response()
6301 },
6302 )
6303}
6304
6305fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6306 let Some(path) = csv_path else {
6307 let html = render_error_artifact_html(
6308 "CSV report was not generated for this run, or was not recorded in \
6309 the scan registry."
6310 .to_string(),
6311 Some(format!("/runs/html/{run_id}")),
6312 Some("View HTML Report".to_string()),
6313 Some(run_id.to_string()),
6314 Some(404),
6315 csp_nonce,
6316 );
6317 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6318 };
6319 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
6320}
6321
6322fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
6323 let Some(path) = xlsx_path else {
6324 let html = render_error_artifact_html(
6325 "Excel report was not generated for this run, or was not recorded in \
6326 the scan registry."
6327 .to_string(),
6328 Some(format!("/runs/html/{run_id}")),
6329 Some("View HTML Report".to_string()),
6330 Some(run_id.to_string()),
6331 Some(404),
6332 csp_nonce,
6333 );
6334 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6335 };
6336 serve_binary_download(
6337 &path,
6338 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6339 "report.xlsx",
6340 )
6341}
6342
6343fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
6344 let path = artifact_set
6345 .scan_config_path
6346 .as_deref()
6347 .map(std::path::Path::to_path_buf)
6348 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
6349 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
6350 fs::read(&path).map_or_else(
6351 |_| StatusCode::NOT_FOUND.into_response(),
6352 |bytes| {
6353 (
6354 [
6355 (
6356 header::CONTENT_TYPE,
6357 "application/json; charset=utf-8".to_string(),
6358 ),
6359 (
6360 header::CONTENT_DISPOSITION,
6361 "attachment; filename=\"scan-config.json\"".to_string(),
6362 ),
6363 ],
6364 bytes,
6365 )
6366 .into_response()
6367 },
6368 )
6369}
6370
6371async fn serve_submodule_pdf_arm(
6376 artifact: &str,
6377 artifact_set: RunArtifacts,
6378 wants_download: bool,
6379 run_id: &str,
6380 csp_nonce: &str,
6381) -> Response {
6382 let base = artifact.trim_end_matches("_pdf");
6384 let sub_dir = artifact_set.output_dir.join("submodules");
6385 let pdf_path = sub_dir.join(format!("{base}.pdf"));
6386
6387 if !pdf_path.exists() {
6388 let derived_safe = base.trim_start_matches("sub_");
6390 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
6391 let parent_run = read_json(jp).ok()?;
6392 let sub = parent_run
6393 .submodule_summaries
6394 .iter()
6395 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
6396 .clone();
6397 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
6398 Some((parent_run, sub, parent_path))
6399 });
6400
6401 if let Some((parent_run, sub, parent_path)) = rebuilt {
6402 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
6403 let pp = pdf_path.clone();
6404 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
6405 }
6406 }
6407
6408 if !pdf_path.exists() {
6409 let html = render_error_artifact_html(
6410 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
6411 enabled."
6412 .to_string(),
6413 Some("/view-reports".to_string()),
6414 Some("View Reports".to_string()),
6415 Some(run_id.to_string()),
6416 Some(404),
6417 csp_nonce,
6418 );
6419 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6420 }
6421
6422 serve_pdf_artifact(
6423 &pdf_path,
6424 &artifact_set.report_title,
6425 run_id,
6426 wants_download,
6427 csp_nonce,
6428 )
6429}
6430
6431fn serve_submodule_arm(
6432 artifact: &str,
6433 artifact_set: &RunArtifacts,
6434 wants_download: bool,
6435 csp_nonce: &str,
6436 run_id: &str,
6437 server_mode: bool,
6438) -> Response {
6439 if artifact.len() > 128
6440 || !artifact
6441 .chars()
6442 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
6443 {
6444 return StatusCode::BAD_REQUEST.into_response();
6445 }
6446 let filename = format!("{artifact}.html");
6447 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
6449 let path = if new_layout.exists() {
6450 new_layout
6451 } else {
6452 artifact_set.output_dir.join(&filename)
6453 };
6454 if !path.exists() {
6455 let html = render_error_artifact_html(
6456 format!(
6457 "Sub-report '{artifact}' was not found in the run directory.\n\
6458 Re-run the analysis with 'Detect and separate git submodules' \
6459 and HTML output enabled."
6460 ),
6461 Some("/view-reports".to_string()),
6462 Some("View Reports".to_string()),
6463 Some(run_id.to_string()),
6464 Some(404),
6465 csp_nonce,
6466 );
6467 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6468 }
6469 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
6470}
6471
6472async fn serve_pdf_arm(
6473 state: &AppState,
6474 artifact_set: RunArtifacts,
6475 wants_download: bool,
6476 run_id: &str,
6477 csp_nonce: &str,
6478) -> Response {
6479 let report_title = artifact_set.report_title.clone();
6480 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
6481 let stale_html_name = artifact_set
6482 .html_path
6483 .as_deref()
6484 .and_then(|p| p.file_name())
6485 .map(|n| n.to_string_lossy().into_owned());
6486 let path = match resolve_or_queue_pdf(
6487 state,
6488 artifact_set.pdf_path,
6489 artifact_set.json_path.clone(),
6490 artifact_set.output_dir.clone(),
6491 run_id,
6492 &report_title,
6493 csp_nonce,
6494 )
6495 .await
6496 {
6497 Ok(p) => p,
6498 Err(r) => return r,
6499 };
6500 if !path.exists() {
6501 if had_pdf_in_registry {
6505 if let Some(expected_filename) = stale_html_name {
6506 let html = LocateFileTemplate {
6507 run_id: run_id.to_string(),
6508 artifact_type: "pdf".to_string(),
6509 expected_filename,
6510 server_mode: state.server_mode,
6511 csp_nonce: csp_nonce.to_string(),
6512 version: env!("CARGO_PKG_VERSION"),
6513 }
6514 .render()
6515 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6516 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6517 }
6518 }
6519 return pdf_generating_response(run_id, csp_nonce);
6520 }
6521 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
6522}
6523
6524async fn artifact_handler(
6525 State(state): State<AppState>,
6526 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6527 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
6528 Query(query): Query<ArtifactQuery>,
6529) -> Response {
6530 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
6531 Ok(a) => a,
6532 Err(r) => return r,
6533 };
6534
6535 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
6536
6537 match artifact.as_str() {
6538 "html" => {
6539 let Some(path) = artifact_set.html_path else {
6540 return StatusCode::NOT_FOUND.into_response();
6541 };
6542 serve_html_artifact(
6543 &path,
6544 wants_download,
6545 &csp_nonce,
6546 &run_id,
6547 state.server_mode,
6548 )
6549 }
6550 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
6551 "json" => {
6552 let Some(path) = artifact_set.json_path else {
6553 let html = render_error_artifact_html(
6554 "JSON result was not generated for this run, or was not recorded in \
6555 the scan registry. Re-run the analysis with JSON output enabled."
6556 .to_string(),
6557 Some("/view-reports".to_string()),
6558 Some("View Reports".to_string()),
6559 Some(run_id.clone()),
6560 Some(404),
6561 &csp_nonce,
6562 );
6563 return (StatusCode::NOT_FOUND, Html(html)).into_response();
6564 };
6565 serve_json_artifact(&path, wants_download, &csp_nonce)
6566 }
6567 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
6568 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
6569 "scan-config" => serve_scan_config_arm(&artifact_set),
6570 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
6571 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
6572 .await
6573 }
6574 _ if artifact.starts_with("sub_") => serve_submodule_arm(
6575 &artifact,
6576 &artifact_set,
6577 wants_download,
6578 &csp_nonce,
6579 &run_id,
6580 state.server_mode,
6581 ),
6582 _ => StatusCode::NOT_FOUND.into_response(),
6583 }
6584}
6585
6586struct SubmoduleLinkRow {
6589 name: String,
6590 url: String,
6591}
6592
6593struct HistoryEntryRow {
6594 run_id: String,
6595 run_id_short: String,
6596 timestamp: String,
6597 timestamp_utc_ms: i64,
6598 project_label: String,
6599 project_path: String,
6600 files_analyzed: u64,
6601 files_skipped: u64,
6602 code_lines: u64,
6603 comment_lines: u64,
6604 blank_lines: u64,
6605 git_branch: String,
6606 git_commit: String,
6607 has_html: bool,
6608 has_json: bool,
6609 has_pdf: bool,
6610 submodule_links: Vec<SubmoduleLinkRow>,
6611 submodule_names_csv: String,
6613}
6614
6615fn nth_weekday_of_month(
6617 year: i32,
6618 month: u32,
6619 weekday: chrono::Weekday,
6620 n: u32,
6621) -> chrono::NaiveDate {
6622 use chrono::Datelike;
6623 let mut count = 0u32;
6624 let mut day = 1u32;
6625 loop {
6626 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
6627 if d.weekday() == weekday {
6628 count += 1;
6629 if count == n {
6630 return d;
6631 }
6632 }
6633 day += 1;
6634 }
6635}
6636
6637fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
6641 use chrono::{Datelike, TimeZone};
6642 let year = dt.year();
6643 let dst_start = chrono::Utc.from_utc_datetime(
6644 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
6645 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
6646 );
6647 let dst_end = chrono::Utc.from_utc_datetime(
6648 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
6649 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
6650 );
6651 dt >= dst_start && dt < dst_end
6652}
6653
6654fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
6655 if is_pacific_dst(dt) {
6656 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
6657 .format("%Y-%m-%d %H:%M PDT")
6658 .to_string()
6659 } else {
6660 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
6661 .format("%Y-%m-%d %H:%M PST")
6662 .to_string()
6663 }
6664}
6665
6666fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
6668 let (offset, tz) = if is_pacific_dst(dt) {
6669 (
6670 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
6671 "PDT",
6672 )
6673 } else {
6674 (
6675 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
6676 "PST",
6677 )
6678 };
6679 format!(
6680 "{} {tz}",
6681 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
6682 )
6683}
6684
6685fn fmt_git_date(iso: &str) -> Option<String> {
6686 chrono::DateTime::parse_from_rfc3339(iso)
6687 .ok()
6688 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
6689}
6690
6691fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
6692 reg.entries
6693 .iter()
6694 .map(|e| {
6695 let submodule_links = {
6696 let mut links: Vec<SubmoduleLinkRow> = vec![];
6697 let sub_dir = e
6698 .html_path
6699 .as_ref()
6700 .and_then(|p| p.parent())
6701 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
6702 if let Some(dir) = sub_dir {
6703 if let Ok(rd) = std::fs::read_dir(dir) {
6704 for entry_res in rd.flatten() {
6705 let fname = entry_res.file_name();
6706 let fname_str = fname.to_string_lossy();
6707 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
6708 let stem = &fname_str[..fname_str.len() - 5];
6709 let display = stem[4..].replace('-', " ");
6710 links.push(SubmoduleLinkRow {
6711 name: display,
6712 url: format!("/runs/{stem}/{}", e.run_id),
6713 });
6714 }
6715 }
6716 }
6717 }
6718 links.sort_by(|a, b| a.name.cmp(&b.name));
6719 links
6720 };
6721 let submodule_names_csv = submodule_links
6722 .iter()
6723 .map(|l| l.name.as_str())
6724 .collect::<Vec<_>>()
6725 .join(",");
6726 HistoryEntryRow {
6727 run_id: e.run_id.clone(),
6728 run_id_short: e
6729 .run_id
6730 .split('-')
6731 .next_back()
6732 .unwrap_or(&e.run_id)
6733 .chars()
6734 .take(7)
6735 .collect(),
6736 timestamp: fmt_la_time(e.timestamp_utc),
6737 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
6738 project_label: e.project_label.clone(),
6739 project_path: e
6740 .input_roots
6741 .first()
6742 .map(|s| sanitize_path_str(s))
6743 .unwrap_or_default(),
6744 files_analyzed: e.summary.files_analyzed,
6745 files_skipped: e.summary.files_skipped,
6746 code_lines: e.summary.code_lines,
6747 comment_lines: e.summary.comment_lines,
6748 blank_lines: e.summary.blank_lines,
6749 git_branch: e.git_branch.clone().unwrap_or_default(),
6750 git_commit: e.git_commit.clone().unwrap_or_default(),
6751 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
6752 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
6753 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
6754 submodule_links,
6755 submodule_names_csv,
6756 }
6757 })
6758 .collect()
6759}
6760
6761#[derive(Deserialize, Default)]
6762struct HistoryQuery {
6763 linked: Option<String>,
6764 error: Option<String>,
6765}
6766
6767async fn history_handler(
6768 State(state): State<AppState>,
6769 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6770 Query(query): Query<HistoryQuery>,
6771) -> impl IntoResponse {
6772 auto_scan_watched_dirs(&state).await;
6774 let watched_dirs: Vec<String> = {
6775 let wd = state.watched_dirs.lock().await;
6776 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6777 };
6778 let mut entries = {
6779 let reg = state.registry.lock().await;
6780 make_history_rows(®)
6781 };
6782 entries.retain(|e| e.has_html);
6783 let total_scans = entries.len();
6784 let linked_count = query
6785 .linked
6786 .as_deref()
6787 .and_then(|s| s.parse::<usize>().ok())
6788 .unwrap_or(0);
6789 let browse_error = query.error.filter(|s| !s.is_empty());
6790 let template = HistoryTemplate {
6791 version: env!("CARGO_PKG_VERSION"),
6792 entries,
6793 total_scans,
6794 linked_count,
6795 browse_error,
6796 watched_dirs,
6797 csp_nonce,
6798 server_mode: state.server_mode,
6799 };
6800 Html(
6801 template
6802 .render()
6803 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6804 )
6805 .into_response()
6806}
6807
6808async fn compare_select_handler(
6809 State(state): State<AppState>,
6810 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6811) -> impl IntoResponse {
6812 auto_scan_watched_dirs(&state).await;
6813 let watched_dirs: Vec<String> = {
6814 let wd = state.watched_dirs.lock().await;
6815 wd.dirs.iter().map(|p| p.display().to_string()).collect()
6816 };
6817 let mut entries = {
6818 let reg = state.registry.lock().await;
6819 make_history_rows(®)
6820 };
6821 entries.retain(|e| e.has_json);
6822 let total_scans = entries.len();
6823 let template = CompareSelectTemplate {
6824 version: env!("CARGO_PKG_VERSION"),
6825 entries,
6826 total_scans,
6827 watched_dirs,
6828 csp_nonce,
6829 server_mode: state.server_mode,
6830 };
6831 Html(
6832 template
6833 .render()
6834 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
6835 )
6836 .into_response()
6837}
6838
6839#[derive(Deserialize, Default)]
6842struct CompareQuery {
6843 a: Option<String>,
6844 b: Option<String>,
6845 sub: Option<String>,
6847 scope: Option<String>,
6849}
6850
6851struct CompareFileDeltaRow {
6852 relative_path: String,
6853 language: String,
6854 status: String,
6855 baseline_code: i64,
6856 current_code: i64,
6857 baseline_code_display: String,
6858 current_code_display: String,
6859 code_delta_str: String,
6860 code_delta_class: String,
6861 comment_delta_str: String,
6862 comment_delta_class: String,
6863 total_delta_str: String,
6864 total_delta_class: String,
6865}
6866
6867fn recompute_summary_from_records(run: &mut AnalysisRun) {
6870 let mut totals = SummaryTotals::default();
6871 for r in &run.per_file_records {
6872 if r.language.is_some() {
6873 totals.files_analyzed += 1;
6874 }
6875 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
6876 totals.code_lines += r.effective_counts.code_lines;
6877 totals.comment_lines += r.effective_counts.comment_lines;
6878 totals.blank_lines += r.effective_counts.blank_lines;
6879 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
6880 totals.functions += r.raw_line_categories.functions;
6881 totals.classes += r.raw_line_categories.classes;
6882 totals.variables += r.raw_line_categories.variables;
6883 totals.imports += r.raw_line_categories.imports;
6884 totals.test_count += r.raw_line_categories.test_count;
6885 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
6886 totals.test_suite_count += r.raw_line_categories.test_suite_count;
6887 if let Some(cov) = &r.coverage {
6888 totals.coverage_lines_found += u64::from(cov.lines_found);
6889 totals.coverage_lines_hit += u64::from(cov.lines_hit);
6890 totals.coverage_functions_found += u64::from(cov.functions_found);
6891 totals.coverage_functions_hit += u64::from(cov.functions_hit);
6892 totals.coverage_branches_found += u64::from(cov.branches_found);
6893 totals.coverage_branches_hit += u64::from(cov.branches_hit);
6894 }
6895 }
6896 totals.files_considered = totals.files_analyzed;
6897 run.summary_totals = totals;
6898}
6899
6900fn fmt_delta(n: i64) -> String {
6901 if n > 0 {
6902 format!("+{n}")
6903 } else {
6904 format!("{n}")
6905 }
6906}
6907
6908fn delta_class(n: i64) -> &'static str {
6909 use std::cmp::Ordering;
6910 match n.cmp(&0) {
6911 Ordering::Greater => "pos",
6912 Ordering::Less => "neg",
6913 Ordering::Equal => "zero",
6914 }
6915}
6916
6917#[allow(clippy::cast_precision_loss)]
6919fn fmt_pct(delta: i64, baseline: u64) -> String {
6920 if baseline == 0 {
6921 return "—".to_string();
6922 }
6923 #[allow(clippy::cast_precision_loss)]
6924 let pct = (delta as f64 / baseline as f64) * 100.0;
6925 if pct > 0.049 {
6926 format!("+{pct:.1}%")
6927 } else if pct < -0.049 {
6928 format!("{pct:.1}%")
6929 } else {
6930 "±0%".to_string()
6931 }
6932}
6933
6934fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
6936 prev.map_or_else(
6937 || ("—".to_string(), "na"),
6938 |p| {
6939 #[allow(clippy::cast_possible_wrap)]
6940 let d = curr as i64 - p as i64;
6941 (fmt_delta(d), delta_class(d))
6942 },
6943 )
6944}
6945
6946#[allow(clippy::result_large_err)] fn load_scan_for_compare(
6948 json_path: &std::path::Path,
6949 scan_label: &str,
6950 run_id: &str,
6951 server_mode: bool,
6952 compare_url: &str,
6953 csp_nonce: &str,
6954) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
6955 match read_json(json_path) {
6956 Ok(r) => Ok(r),
6957 Err(e) => {
6958 if server_mode {
6959 let html = ErrorTemplate {
6960 message: format!(
6961 "Could not load {scan_label} scan data. The scan output folder may have \
6962 been moved, renamed, or deleted. Re-running the analysis will create \
6963 fresh comparison data."
6964 ),
6965 last_report_url: Some("/compare-scans".to_string()),
6966 last_report_label: Some("Compare Scans".to_string()),
6967 run_id: Some(run_id.to_owned()),
6968 error_code: Some(404),
6969 csp_nonce: csp_nonce.to_owned(),
6970 version: env!("CARGO_PKG_VERSION"),
6971 }
6972 .render()
6973 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
6974 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6975 }
6976 let msg = format!(
6977 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
6978 json_path.display()
6979 );
6980 let folder_hint = json_path
6981 .parent()
6982 .map(|p| p.display().to_string())
6983 .unwrap_or_default();
6984 Err(missing_scan_relocate_response(
6985 &msg,
6986 run_id,
6987 &folder_hint,
6988 compare_url,
6989 false,
6990 csp_nonce,
6991 ))
6992 }
6993 }
6994}
6995
6996struct ChurnStats {
6997 new_scope: bool,
6998 scope_flag: bool,
6999 churn_rate_str: String,
7000 churn_rate_class: String,
7001}
7002
7003fn compute_churn_stats(
7004 baseline_code: u64,
7005 current_code: u64,
7006 lines_added: i64,
7007 lines_removed: i64,
7008) -> ChurnStats {
7009 let new_scope = baseline_code == 0 && current_code > 0;
7010 #[allow(clippy::cast_precision_loss)]
7011 let churn_pct = if baseline_code > 0 {
7012 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7013 } else {
7014 0.0
7015 };
7016 #[allow(clippy::cast_precision_loss)]
7017 let scope_flag =
7018 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7019 let churn_rate_str = if new_scope {
7020 "New".to_string()
7021 } else if baseline_code > 0 {
7022 format!("{churn_pct:.1}%")
7023 } else {
7024 "—".to_string()
7025 };
7026 let churn_rate_class = if new_scope || churn_pct > 20.0 {
7027 "high".to_string()
7028 } else if churn_pct > 5.0 {
7029 "med".to_string()
7030 } else {
7031 "low".to_string()
7032 };
7033 ChurnStats {
7034 new_scope,
7035 scope_flag,
7036 churn_rate_str,
7037 churn_rate_class,
7038 }
7039}
7040
7041fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7045 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7046 if !has_data {
7047 return String::new();
7048 }
7049 let base_str = s
7050 .baseline_coverage_line_pct
7051 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7052 let curr_str = s
7053 .current_coverage_line_pct
7054 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7055 let (delta_str, cls) = match s.coverage_line_pct_delta {
7056 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7057 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7058 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7059 None => ("\u{2014}".into(), "zero"),
7060 };
7061 format!(
7062 r#"<div class="delta-card">
7063 <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>
7064 <div class="delta-card-label">Line coverage</div>
7065 <div class="delta-card-from">Before: {base_str}</div>
7066 <div class="delta-card-to">{curr_str}</div>
7067 <span class="delta-card-change {cls}">{delta_str}</span>
7068 </div>"#
7069 )
7070}
7071
7072#[allow(clippy::ref_option)]
7074fn narrow_run_pair_by_scope(
7075 mut baseline: AnalysisRun,
7076 mut current: AnalysisRun,
7077 active_sub: &Option<String>,
7078 super_scope: bool,
7079) -> (AnalysisRun, AnalysisRun) {
7080 if let Some(ref sub_name) = active_sub {
7081 baseline
7082 .per_file_records
7083 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7084 current
7085 .per_file_records
7086 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7087 recompute_summary_from_records(&mut baseline);
7088 recompute_summary_from_records(&mut current);
7089 } else if super_scope {
7090 baseline.per_file_records.retain(|f| f.submodule.is_none());
7091 current.per_file_records.retain(|f| f.submodule.is_none());
7092 recompute_summary_from_records(&mut baseline);
7093 recompute_summary_from_records(&mut current);
7094 }
7095 (baseline, current)
7096}
7097
7098#[allow(clippy::ref_option)]
7100fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7101 if let Some(ref sub_name) = active_sub {
7102 for run in runs.iter_mut() {
7103 run.per_file_records
7104 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7105 recompute_summary_from_records(run);
7106 }
7107 } else if super_scope {
7108 for run in runs.iter_mut() {
7109 run.per_file_records.retain(|f| f.submodule.is_none());
7110 recompute_summary_from_records(run);
7111 }
7112 }
7113}
7114
7115#[allow(clippy::too_many_lines)]
7116async fn compare_handler(
7117 State(state): State<AppState>,
7118 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7119 Query(query): Query<CompareQuery>,
7120) -> impl IntoResponse {
7121 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7124 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7125 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7126 };
7127
7128 let (maybe_a, maybe_b) = {
7129 let reg = state.registry.lock().await;
7130 (
7131 reg.find_by_run_id(&run_id_a).cloned(),
7132 reg.find_by_run_id(&run_id_b).cloned(),
7133 )
7134 };
7135
7136 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7137 let html = ErrorTemplate {
7138 message: "One or both run IDs were not found in scan history. \
7139 The runs may have been deleted or the registry may have been reset."
7140 .to_string(),
7141 last_report_url: Some("/compare-scans".to_string()),
7142 last_report_label: Some("Compare Scans".to_string()),
7143 run_id: None,
7144 error_code: None,
7145 csp_nonce: csp_nonce.clone(),
7146 version: env!("CARGO_PKG_VERSION"),
7147 }
7148 .render()
7149 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7150 return Html(html).into_response();
7151 };
7152
7153 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7155 (entry_a, entry_b)
7156 } else {
7157 (entry_b, entry_a)
7158 };
7159
7160 if baseline_entry.run_id != run_id_a {
7164 let canonical = format!(
7165 "/compare?a={}&b={}",
7166 baseline_entry.run_id, current_entry.run_id
7167 );
7168 return axum::response::Redirect::to(&canonical).into_response();
7169 }
7170
7171 let (Some(base_json), Some(curr_json)) = (
7172 baseline_entry.json_path.as_ref(),
7173 current_entry.json_path.as_ref(),
7174 ) else {
7175 let html = ErrorTemplate {
7176 message: "Full comparison requires JSON scan data, which was not saved for one or \
7177 both of these runs. JSON is now always saved for new scans — re-run the \
7178 affected projects to enable comparisons."
7179 .to_string(),
7180 last_report_url: Some("/compare-scans".to_string()),
7181 last_report_label: Some("Compare Scans".to_string()),
7182 run_id: None,
7183 error_code: None,
7184 csp_nonce: csp_nonce.clone(),
7185 version: env!("CARGO_PKG_VERSION"),
7186 }
7187 .render()
7188 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
7189 return Html(html).into_response();
7190 };
7191
7192 let compare_url = format!(
7193 "/compare?a={}&b={}",
7194 baseline_entry.run_id, current_entry.run_id
7195 );
7196
7197 let baseline_run = match load_scan_for_compare(
7198 base_json,
7199 "baseline",
7200 &baseline_entry.run_id,
7201 state.server_mode,
7202 &compare_url,
7203 &csp_nonce,
7204 ) {
7205 Ok(r) => r,
7206 Err(resp) => return resp,
7207 };
7208 let current_run = match load_scan_for_compare(
7209 curr_json,
7210 "current",
7211 ¤t_entry.run_id,
7212 state.server_mode,
7213 &compare_url,
7214 &csp_nonce,
7215 ) {
7216 Ok(r) => r,
7217 Err(resp) => return resp,
7218 };
7219
7220 let active_submodule = query.sub.clone();
7221 let super_scope_active = query.scope.as_deref() == Some("super");
7222
7223 let submodule_options = baseline_run
7224 .submodule_summaries
7225 .iter()
7226 .chain(current_run.submodule_summaries.iter())
7227 .map(|s| s.name.clone())
7228 .collect::<std::collections::BTreeSet<_>>()
7229 .into_iter()
7230 .collect::<Vec<_>>();
7231 let has_any_submodule_data = !submodule_options.is_empty();
7232
7233 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
7235 baseline_run,
7236 current_run,
7237 &active_submodule,
7238 super_scope_active,
7239 );
7240
7241 let comparison = compute_delta(&effective_baseline, &effective_current);
7242
7243 let file_rows: Vec<CompareFileDeltaRow> = comparison
7244 .file_deltas
7245 .iter()
7246 .map(|d| CompareFileDeltaRow {
7247 relative_path: d.relative_path.clone(),
7248 language: d.language.clone().unwrap_or_else(|| "—".into()),
7249 status: match d.status {
7250 FileChangeStatus::Added => "added".into(),
7251 FileChangeStatus::Removed => "removed".into(),
7252 FileChangeStatus::Modified => "modified".into(),
7253 FileChangeStatus::Unchanged => "unchanged".into(),
7254 },
7255 baseline_code: d.baseline_code,
7256 current_code: d.current_code,
7257 baseline_code_display: if d.status == FileChangeStatus::Added {
7258 "—".into()
7259 } else {
7260 d.baseline_code.to_string()
7261 },
7262 current_code_display: if d.status == FileChangeStatus::Removed {
7263 "—".into()
7264 } else {
7265 d.current_code.to_string()
7266 },
7267 code_delta_str: fmt_delta(d.code_delta),
7268 code_delta_class: delta_class(d.code_delta).into(),
7269 comment_delta_str: fmt_delta(d.comment_delta),
7270 comment_delta_class: delta_class(d.comment_delta).into(),
7271 total_delta_str: fmt_delta(d.total_delta),
7272 total_delta_class: delta_class(d.total_delta).into(),
7273 })
7274 .collect();
7275
7276 let project_path = baseline_entry
7277 .input_roots
7278 .first()
7279 .map(|s| sanitize_path_str(s))
7280 .unwrap_or_default();
7281 let lines_added = sum_added_code_lines(&comparison);
7282 let lines_removed = sum_removed_code_lines(&comparison);
7283 let churn = compute_churn_stats(
7284 comparison.summary.baseline_code,
7285 comparison.summary.current_code,
7286 lines_added,
7287 lines_removed,
7288 );
7289 let s = &comparison.summary;
7290 let template = CompareTemplate {
7291 version: env!("CARGO_PKG_VERSION"),
7292 project_label: baseline_entry.project_label.clone(),
7293 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
7294 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
7295 baseline_run_id: baseline_entry.run_id.clone(),
7296 current_run_id: current_entry.run_id.clone(),
7297 baseline_run_id_short: baseline_entry
7298 .run_id
7299 .split('-')
7300 .next_back()
7301 .unwrap_or(&baseline_entry.run_id)
7302 .chars()
7303 .take(7)
7304 .collect(),
7305 current_run_id_short: current_entry
7306 .run_id
7307 .split('-')
7308 .next_back()
7309 .unwrap_or(¤t_entry.run_id)
7310 .chars()
7311 .take(7)
7312 .collect(),
7313 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
7314 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
7315 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
7316 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
7317 project_path: project_path.clone(),
7318 baseline_code: s.baseline_code,
7319 current_code: s.current_code,
7320 code_lines_delta_str: fmt_delta(s.code_lines_delta),
7321 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
7322 baseline_files: s.baseline_files,
7323 current_files: s.current_files,
7324 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
7325 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
7326 baseline_comments: s.baseline_comments,
7327 current_comments: s.current_comments,
7328 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
7329 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
7330 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
7331 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
7332 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
7333 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
7334 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
7335 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
7336 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
7337 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
7338 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
7339 code_lines_added: lines_added,
7340 code_lines_removed: lines_removed,
7341 new_scope: churn.new_scope,
7342 churn_rate_str: churn.churn_rate_str,
7343 churn_rate_class: churn.churn_rate_class,
7344 scope_flag: churn.scope_flag,
7345 files_added: comparison.files_added,
7346 files_removed: comparison.files_removed,
7347 files_modified: comparison.files_modified,
7348 files_unchanged: comparison.files_unchanged,
7349 file_rows,
7350 baseline_git_author: baseline_entry.git_author.clone(),
7351 current_git_author: current_entry.git_author.clone(),
7352 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
7353 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
7354 baseline_git_tags: baseline_entry.git_tags.clone(),
7355 current_git_tags: current_entry.git_tags.clone(),
7356 baseline_git_commit_date: baseline_entry
7357 .git_commit_date
7358 .as_deref()
7359 .and_then(fmt_git_date),
7360 current_git_commit_date: current_entry
7361 .git_commit_date
7362 .as_deref()
7363 .and_then(fmt_git_date),
7364 project_name: project_path
7365 .rsplit(['/', '\\'])
7366 .find(|s| !s.is_empty())
7367 .unwrap_or(&project_path)
7368 .to_string(),
7369 submodule_options,
7370 has_any_submodule_data,
7371 active_submodule,
7372 super_scope_active,
7373 csp_nonce,
7374 coverage_delta_card: build_coverage_delta_card(s),
7375 baseline_test_count: effective_baseline.summary_totals.test_count,
7376 current_test_count: effective_current.summary_totals.test_count,
7377 baseline_coverage_pct: s.baseline_coverage_line_pct,
7378 current_coverage_pct: s.current_coverage_line_pct,
7379 };
7380
7381 Html(
7382 template
7383 .render()
7384 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7385 )
7386 .into_response()
7387}
7388
7389fn format_number(n: u64) -> String {
7397 let s = n.to_string();
7398 let mut out = String::with_capacity(s.len() + s.len() / 3);
7399 let len = s.len();
7400 for (i, c) in s.chars().enumerate() {
7401 if i > 0 && (len - i).is_multiple_of(3) {
7402 out.push(',');
7403 }
7404 out.push(c);
7405 }
7406 out
7407}
7408
7409const fn badge_char_width(c: char) -> f64 {
7410 match c {
7411 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
7412 'm' | 'w' => 9.0,
7413 ' ' => 4.0,
7414 _ => 6.5,
7415 }
7416}
7417
7418#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7419fn badge_text_px(text: &str) -> u32 {
7420 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
7421}
7422
7423fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
7424 let lw = badge_text_px(label) + 20;
7425 let rw = badge_text_px(value) + 20;
7426 let total = lw + rw;
7427 let lx = lw / 2;
7428 let rx = lw + rw / 2;
7429 let le = escape_html(label);
7430 let ve = escape_html(value);
7431 let ce = escape_html(color);
7432 format!(
7433 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
7434 <rect width="{total}" height="20" fill="#555"/>
7435 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
7436 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
7437 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
7438 <text x="{lx}" y="13">{le}</text>
7439 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
7440 <text x="{rx}" y="13">{ve}</text>
7441 </g>
7442</svg>"##
7443 )
7444}
7445
7446#[derive(Deserialize)]
7447struct BadgeQuery {
7448 label: Option<String>,
7449 color: Option<String>,
7450}
7451
7452async fn badge_handler(
7453 State(state): State<AppState>,
7454 AxumPath(metric): AxumPath<String>,
7455 Query(query): Query<BadgeQuery>,
7456) -> Response {
7457 let entry = {
7458 let reg = state.registry.lock().await;
7459 reg.entries.first().cloned()
7460 };
7461
7462 let Some(entry) = entry else {
7463 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
7464 return (
7465 [
7466 (header::CONTENT_TYPE, "image/svg+xml"),
7467 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7468 ],
7469 svg,
7470 )
7471 .into_response();
7472 };
7473
7474 let (default_label, value, default_color) = match metric.as_str() {
7475 "code-lines" => (
7476 "code lines",
7477 format_number(entry.summary.code_lines),
7478 "#4a78ee",
7479 ),
7480 "files" => (
7481 "files analyzed",
7482 format_number(entry.summary.files_analyzed),
7483 "#4a9862",
7484 ),
7485 "comment-lines" => (
7486 "comment lines",
7487 format_number(entry.summary.comment_lines),
7488 "#b35428",
7489 ),
7490 "blank-lines" => (
7491 "blank lines",
7492 format_number(entry.summary.blank_lines),
7493 "#7a5db0",
7494 ),
7495 _ => return StatusCode::NOT_FOUND.into_response(),
7496 };
7497
7498 let label = query.label.as_deref().unwrap_or(default_label);
7499 let color = query.color.as_deref().unwrap_or(default_color);
7500 let svg = render_badge_svg(label, &value, color);
7501
7502 (
7503 [
7504 (header::CONTENT_TYPE, "image/svg+xml"),
7505 (header::CACHE_CONTROL, "no-cache, max-age=0"),
7506 ],
7507 svg,
7508 )
7509 .into_response()
7510}
7511
7512#[derive(Serialize)]
7520struct ApiCoverageBlock {
7521 lines_found: u64,
7522 lines_hit: u64,
7523 line_pct: f64,
7524 functions_found: u64,
7525 functions_hit: u64,
7526 function_pct: f64,
7527 branches_found: u64,
7528 branches_hit: u64,
7529 branch_pct: f64,
7530}
7531
7532#[derive(Serialize)]
7533struct ApiMetricsResponse {
7534 run_id: String,
7535 timestamp: String,
7536 project: String,
7537 summary: ApiSummaryPayload,
7538 languages: Vec<ApiLanguageRow>,
7539 #[serde(skip_serializing_if = "Option::is_none")]
7540 coverage: Option<ApiCoverageBlock>,
7541}
7542
7543#[derive(Serialize)]
7544struct ApiSummaryPayload {
7545 files_analyzed: u64,
7546 files_skipped: u64,
7547 code_lines: u64,
7548 comment_lines: u64,
7549 blank_lines: u64,
7550 total_physical_lines: u64,
7551 functions: u64,
7552 classes: u64,
7553 variables: u64,
7554 imports: u64,
7555}
7556
7557#[derive(Serialize)]
7558struct ApiLanguageRow {
7559 name: String,
7560 files: u64,
7561 code_lines: u64,
7562 comment_lines: u64,
7563 blank_lines: u64,
7564 functions: u64,
7565 classes: u64,
7566 variables: u64,
7567 imports: u64,
7568}
7569
7570async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
7571 let entry = {
7572 let reg = state.registry.lock().await;
7573 reg.entries.first().cloned()
7574 };
7575 entry.map_or_else(
7576 || error::not_found("no scans recorded yet"),
7577 |e| build_metrics_response(&e),
7578 )
7579}
7580
7581async fn api_metrics_run_handler(
7582 State(state): State<AppState>,
7583 AxumPath(run_id): AxumPath<String>,
7584) -> Response {
7585 let entry = {
7586 let reg = state.registry.lock().await;
7587 reg.find_by_run_id(&run_id).cloned()
7588 };
7589 entry.map_or_else(
7590 || error::not_found("run not found"),
7591 |e| build_metrics_response(&e),
7592 )
7593}
7594
7595fn build_metrics_response(entry: &RegistryEntry) -> Response {
7596 let languages: Vec<ApiLanguageRow> = entry
7597 .json_path
7598 .as_ref()
7599 .and_then(|p| read_json(p).ok())
7600 .map(|run| {
7601 run.totals_by_language
7602 .iter()
7603 .map(|l| ApiLanguageRow {
7604 name: l.language.display_name().to_string(),
7605 files: l.files,
7606 code_lines: l.code_lines,
7607 comment_lines: l.comment_lines,
7608 blank_lines: l.blank_lines,
7609 functions: l.functions,
7610 classes: l.classes,
7611 variables: l.variables,
7612 imports: l.imports,
7613 })
7614 .collect()
7615 })
7616 .unwrap_or_default();
7617
7618 let s = &entry.summary;
7619 let coverage = if s.coverage_lines_found > 0 {
7620 let pct = |hit: u64, found: u64| -> f64 {
7621 if found == 0 {
7622 0.0
7623 } else {
7624 #[allow(clippy::cast_precision_loss)]
7625 let v = (hit as f64 / found as f64) * 100.0;
7626 (v * 10.0).round() / 10.0
7627 }
7628 };
7629 Some(ApiCoverageBlock {
7630 lines_found: s.coverage_lines_found,
7631 lines_hit: s.coverage_lines_hit,
7632 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
7633 functions_found: s.coverage_functions_found,
7634 functions_hit: s.coverage_functions_hit,
7635 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
7636 branches_found: s.coverage_branches_found,
7637 branches_hit: s.coverage_branches_hit,
7638 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
7639 })
7640 } else {
7641 None
7642 };
7643 Json(ApiMetricsResponse {
7644 run_id: entry.run_id.clone(),
7645 timestamp: entry.timestamp_utc.to_rfc3339(),
7646 project: entry.project_label.clone(),
7647 summary: ApiSummaryPayload {
7648 files_analyzed: s.files_analyzed,
7649 files_skipped: s.files_skipped,
7650 code_lines: s.code_lines,
7651 comment_lines: s.comment_lines,
7652 blank_lines: s.blank_lines,
7653 total_physical_lines: s.total_physical_lines,
7654 functions: s.functions,
7655 classes: s.classes,
7656 variables: s.variables,
7657 imports: s.imports,
7658 },
7659 languages,
7660 coverage,
7661 })
7662 .into_response()
7663}
7664
7665#[derive(Deserialize)]
7672struct ProjectHistoryQuery {
7673 path: Option<String>,
7674}
7675
7676#[derive(Serialize)]
7677struct ProjectHistoryResponse {
7678 scan_count: usize,
7679 last_scan_id: Option<String>,
7680 last_scan_timestamp: Option<String>,
7681 last_scan_code_lines: Option<u64>,
7682 last_git_branch: Option<String>,
7683 last_git_commit: Option<String>,
7684}
7685
7686fn entry_matches_project(
7689 entry: &RegistryEntry,
7690 root_str: &str,
7691 upload_root: &str,
7692 upload_name_suffix: Option<&str>,
7693) -> bool {
7694 if entry.input_roots.iter().any(|r| r == root_str) {
7695 return true;
7696 }
7697 if let Some(suffix) = upload_name_suffix {
7698 return entry
7699 .input_roots
7700 .iter()
7701 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
7702 }
7703 false
7704}
7705
7706async fn project_history_handler(
7707 State(state): State<AppState>,
7708 Query(query): Query<ProjectHistoryQuery>,
7709) -> Response {
7710 let path = query.path.unwrap_or_default();
7711 let resolved = resolve_input_path(&path);
7712 let root_str = resolved.to_string_lossy().replace('\\', "/");
7713
7714 let upload_root = std::env::temp_dir()
7719 .join("oxide-sloc-uploads")
7720 .to_string_lossy()
7721 .replace('\\', "/");
7722 let upload_name_suffix: Option<String> =
7723 if state.server_mode && root_str.starts_with(&upload_root) {
7724 resolved
7725 .file_name()
7726 .and_then(|n| n.to_str())
7727 .map(|name| format!("/{name}"))
7728 } else {
7729 None
7730 };
7731 let suffix_ref = upload_name_suffix.as_deref();
7732
7733 let entries: Vec<_> = {
7734 let reg = state.registry.lock().await;
7735 reg.entries
7736 .iter()
7737 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
7738 .cloned()
7739 .collect()
7740 };
7741 let scan_count = entries.len();
7742 let last = entries.first();
7743 let last_scan_id = last.map(|e| e.run_id.clone());
7744 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
7745 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
7746 let last_git_branch = last.and_then(|e| e.git_branch.clone());
7747 let last_git_commit = last.and_then(|e| e.git_commit.clone());
7748
7749 Json(ProjectHistoryResponse {
7750 scan_count,
7751 last_scan_id,
7752 last_scan_timestamp,
7753 last_scan_code_lines,
7754 last_git_branch,
7755 last_git_commit,
7756 })
7757 .into_response()
7758}
7759
7760#[derive(Deserialize)]
7767struct MetricsHistoryQuery {
7768 root: Option<String>,
7769 limit: Option<usize>,
7770 submodule: Option<String>,
7773}
7774
7775#[derive(Serialize)]
7776struct MetricsSubmoduleLink {
7777 name: String,
7778 url: String,
7779}
7780
7781#[derive(Serialize)]
7782struct MetricsHistoryEntry {
7783 run_id: String,
7784 run_id_short: String,
7785 timestamp: String,
7786 commit: Option<String>,
7787 branch: Option<String>,
7788 tags: Vec<String>,
7789 nearest_tag: Option<String>,
7790 code_lines: u64,
7791 comment_lines: u64,
7792 blank_lines: u64,
7793 physical_lines: u64,
7794 files_analyzed: u64,
7795 files_skipped: u64,
7796 test_count: u64,
7797 project_label: String,
7798 html_url: Option<String>,
7799 has_pdf: bool,
7800 submodule_links: Vec<MetricsSubmoduleLink>,
7801 #[serde(skip_serializing_if = "Option::is_none")]
7803 coverage_line_pct: Option<f64>,
7804}
7805
7806fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
7807 let mut links: Vec<MetricsSubmoduleLink> = vec![];
7808 let sub_dir = e
7809 .html_path
7810 .as_ref()
7811 .and_then(|p| p.parent())
7812 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7813 let Some(dir) = sub_dir else { return links };
7814 let Ok(rd) = std::fs::read_dir(dir) else {
7815 return links;
7816 };
7817 for entry_res in rd.flatten() {
7818 let fname = entry_res.file_name();
7819 let fname_str = fname.to_string_lossy();
7820 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7821 let stem = &fname_str[..fname_str.len() - 5];
7822 let display = stem[4..].replace('-', " ");
7823 links.push(MetricsSubmoduleLink {
7824 name: display,
7825 url: format!("/runs/{stem}/{}", e.run_id),
7826 });
7827 }
7828 }
7829 links.sort_by(|a, b| a.name.cmp(&b.name));
7830 links
7831}
7832
7833fn apply_submodule_filter(
7834 base: MetricsHistoryEntry,
7835 filter: &str,
7836 e: &sloc_core::history::RegistryEntry,
7837) -> Option<MetricsHistoryEntry> {
7838 let json_path = e.json_path.as_ref()?;
7839 let json_str = std::fs::read_to_string(json_path).ok()?;
7840 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
7841 let sub = run
7842 .submodule_summaries
7843 .iter()
7844 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
7845 let safe = sanitize_project_label(&sub.name);
7846 let artifact_key = format!("sub_{safe}");
7847 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
7848 || base.html_url.clone(),
7849 |run_dir| {
7850 let sub_path = run_dir.join(format!("{artifact_key}.html"));
7851 if sub_path.exists() {
7852 Some(format!("/runs/{artifact_key}/{}", e.run_id))
7853 } else {
7854 base.html_url.clone()
7855 }
7856 },
7857 );
7858
7859 let sub_files: Vec<_> = run
7862 .per_file_records
7863 .iter()
7864 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
7865 .collect();
7866 let test_count: u64 = sub_files
7867 .iter()
7868 .map(|r| r.raw_line_categories.test_count)
7869 .sum();
7870 #[allow(clippy::cast_precision_loss)]
7871 let coverage_line_pct: Option<f64> = {
7872 let found: u64 = sub_files
7873 .iter()
7874 .filter_map(|r| r.coverage.as_ref())
7875 .map(|c| u64::from(c.lines_found))
7876 .sum();
7877 let hit: u64 = sub_files
7878 .iter()
7879 .filter_map(|r| r.coverage.as_ref())
7880 .map(|c| u64::from(c.lines_hit))
7881 .sum();
7882 if found > 0 {
7883 let pct = (hit as f64 / found as f64) * 100.0;
7884 Some((pct * 10.0).round() / 10.0)
7885 } else {
7886 None
7887 }
7888 };
7889
7890 Some(MetricsHistoryEntry {
7891 code_lines: sub.code_lines,
7892 comment_lines: sub.comment_lines,
7893 blank_lines: sub.blank_lines,
7894 physical_lines: sub.total_physical_lines,
7895 files_analyzed: sub.files_analyzed,
7896 files_skipped: 0,
7897 test_count,
7898 html_url: sub_html_url,
7899 has_pdf: false,
7900 submodule_links: vec![],
7901 coverage_line_pct,
7902 ..base
7903 })
7904}
7905
7906#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
7908 State(state): State<AppState>,
7909 Query(query): Query<MetricsHistoryQuery>,
7910) -> Response {
7911 let limit = query.limit.unwrap_or(50).min(500);
7912 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
7913
7914 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
7915 let reg = state.registry.lock().await;
7916 reg.entries
7917 .iter()
7918 .filter(|e| {
7919 query.root.as_ref().is_none_or(|root| {
7920 let resolved = resolve_input_path(root);
7921 let root_str = resolved.to_string_lossy().replace('\\', "/");
7922 e.input_roots.iter().any(|r| r == &root_str)
7923 })
7924 })
7925 .take(limit)
7926 .cloned()
7927 .collect()
7928 };
7929
7930 let entries: Vec<MetricsHistoryEntry> = candidate_entries
7931 .into_iter()
7932 .filter_map(|e| {
7933 let tags = e
7934 .git_tags
7935 .as_deref()
7936 .map(|s| {
7937 s.split(',')
7938 .map(|t| t.trim().to_string())
7939 .filter(|t| !t.is_empty())
7940 .collect()
7941 })
7942 .unwrap_or_default();
7943 let html_url = e
7944 .html_path
7945 .as_ref()
7946 .filter(|p| p.exists())
7947 .map(|_| format!("/runs/html/{}", e.run_id));
7948 let nearest_tag = e.git_nearest_tag.clone();
7949 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
7950 let run_id_short: String = e
7951 .run_id
7952 .split('-')
7953 .next_back()
7954 .unwrap_or(&e.run_id)
7955 .chars()
7956 .take(7)
7957 .collect();
7958 let submodule_links = build_entry_submodule_links(&e);
7959 #[allow(clippy::cast_precision_loss)]
7960 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
7961 let pct = (e.summary.coverage_lines_hit as f64
7962 / e.summary.coverage_lines_found as f64)
7963 * 100.0;
7964 Some((pct * 10.0).round() / 10.0)
7965 } else {
7966 None
7967 };
7968 let base = MetricsHistoryEntry {
7969 run_id: e.run_id.clone(),
7970 run_id_short,
7971 timestamp: e.timestamp_utc.to_rfc3339(),
7972 commit: e.git_commit.clone(),
7973 branch: e.git_branch.clone(),
7974 tags,
7975 nearest_tag,
7976 code_lines: e.summary.code_lines,
7977 comment_lines: e.summary.comment_lines,
7978 blank_lines: e.summary.blank_lines,
7979 physical_lines: e.summary.total_physical_lines,
7980 files_analyzed: e.summary.files_analyzed,
7981 files_skipped: e.summary.files_skipped,
7982 test_count: e.summary.test_count,
7983 project_label: e.project_label.clone(),
7984 html_url,
7985 has_pdf,
7986 submodule_links,
7987 coverage_line_pct,
7988 };
7989 if let Some(ref filter) = submodule_filter {
7990 apply_submodule_filter(base, filter, &e)
7991 } else {
7992 Some(base)
7993 }
7994 })
7995 .collect();
7996
7997 Json(entries).into_response()
7998}
7999
8000#[derive(Deserialize)]
8004struct MetricsSubmodulesQuery {
8005 root: Option<String>,
8006}
8007
8008#[derive(Serialize)]
8009struct SubmoduleEntry {
8010 name: String,
8011 relative_path: String,
8012}
8013
8014async fn api_metrics_submodules_handler(
8015 State(state): State<AppState>,
8016 Query(query): Query<MetricsSubmodulesQuery>,
8017) -> Response {
8018 let json_paths: Vec<std::path::PathBuf> = {
8019 let reg = state.registry.lock().await;
8020 reg.entries
8021 .iter()
8022 .filter(|e| {
8023 query.root.as_ref().is_none_or(|root| {
8024 let resolved = resolve_input_path(root);
8025 let root_str = resolved.to_string_lossy().replace('\\', "/");
8026 e.input_roots.iter().any(|r| r == &root_str)
8027 })
8028 })
8029 .filter_map(|e| e.json_path.clone())
8030 .collect()
8031 };
8032
8033 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8034 let mut result: Vec<SubmoduleEntry> = Vec::new();
8035
8036 for path in &json_paths {
8037 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8038 continue;
8039 };
8040 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8041 continue;
8042 };
8043 for sub in &run.submodule_summaries {
8044 if seen.insert(sub.name.clone()) {
8045 result.push(SubmoduleEntry {
8046 name: sub.name.clone(),
8047 relative_path: sub.relative_path.clone(),
8048 });
8049 }
8050 }
8051 }
8052
8053 result.sort_by(|a, b| a.name.cmp(&b.name));
8054 Json(result).into_response()
8055}
8056
8057#[derive(Deserialize)]
8066struct IngestQuery {
8067 label: Option<String>,
8068}
8069
8070#[derive(Serialize)]
8071struct IngestResponse {
8072 run_id: String,
8073 view_url: String,
8074}
8075
8076async fn api_ingest_handler(
8077 State(state): State<AppState>,
8078 Query(q): Query<IngestQuery>,
8079 Json(run): Json<sloc_core::AnalysisRun>,
8080) -> Response {
8081 let label = q.label.unwrap_or_else(|| {
8082 run.input_roots
8083 .first()
8084 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8085 });
8086
8087 let label_for_task = label.clone();
8088 let result = tokio::task::spawn_blocking(move || {
8089 let html = render_html(&run)?;
8090 let run_id = run.tool.run_id.clone();
8091 let run_id_safe = run_id.len() <= 128
8092 && !run_id.is_empty()
8093 && run_id
8094 .chars()
8095 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
8096 if !run_id_safe {
8097 anyhow::bail!(
8098 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
8099 );
8100 }
8101 let project_label = sanitize_project_label(&label_for_task);
8102 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8103 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
8104 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
8105 _ => project_label,
8106 };
8107 let (artifacts, _pending_pdf) = persist_run_artifacts(
8108 &run,
8109 &html,
8110 &output_dir,
8111 &label_for_task,
8112 &file_stem,
8113 RunResultContext::default(),
8114 )?;
8115 Ok::<_, anyhow::Error>((run_id, artifacts, run))
8116 })
8117 .await;
8118
8119 match result {
8120 Ok(Ok((run_id, artifacts, run))) => {
8121 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
8122 (
8123 StatusCode::CREATED,
8124 Json(IngestResponse {
8125 view_url: format!("/view-reports?run_id={run_id}"),
8126 run_id,
8127 }),
8128 )
8129 .into_response()
8130 }
8131 Ok(Err(e)) => error::internal(&format!("{e:#}")),
8132 Err(e) => error::internal(&format!("{e}")),
8133 }
8134}
8135
8136fn html_escape(s: &str) -> String {
8140 s.replace('&', "&")
8141 .replace('<', "<")
8142 .replace('>', ">")
8143 .replace('"', """)
8144}
8145
8146#[allow(clippy::cast_precision_loss)]
8147fn fmt_num(n: i64) -> String {
8148 let a = n.unsigned_abs();
8149 if a >= 1_000_000 {
8150 let v = n as f64 / 1_000_000.0;
8151 let s = format!("{v:.1}");
8152 format!("{}M", s.trim_end_matches(".0"))
8153 } else if a >= 10_000 {
8154 let v = n as f64 / 1_000.0;
8155 let s = format!("{v:.1}");
8156 format!("{}K", s.trim_end_matches(".0"))
8157 } else {
8158 let sign = if n < 0 { "-" } else { "" };
8159 if a < 1_000 {
8160 return format!("{sign}{a}");
8161 }
8162 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
8163 }
8164}
8165
8166fn fmt_comma(n: i64) -> String {
8167 let sign = if n < 0 { "-" } else { "" };
8168 let a = n.unsigned_abs();
8169 if a < 1_000 {
8170 return format!("{sign}{a}");
8171 }
8172 let s = a.to_string();
8173 let bytes = s.as_bytes();
8174 let len = bytes.len();
8175 let mut out = String::with_capacity(len + len / 3);
8176 for (i, &b) in bytes.iter().enumerate() {
8177 if i > 0 && (len - i).is_multiple_of(3) {
8178 out.push(',');
8179 }
8180 out.push(b as char);
8181 }
8182 format!("{sign}{out}")
8183}
8184
8185#[derive(Deserialize, Default)]
8186struct MultiCompareQuery {
8187 runs: Option<String>,
8188 scope: Option<String>,
8190 sub: Option<String>,
8192}
8193
8194#[allow(clippy::too_many_lines)]
8195async fn multi_compare_handler(
8196 State(state): State<AppState>,
8197 Query(params): Query<MultiCompareQuery>,
8198 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8199) -> impl IntoResponse {
8200 let run_ids: Vec<String> = params
8201 .runs
8202 .as_deref()
8203 .unwrap_or("")
8204 .split(',')
8205 .map(|s| s.trim().to_string())
8206 .filter(|s| !s.is_empty())
8207 .collect();
8208
8209 if run_ids.len() < 2 {
8210 return Html(
8211 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
8212 <a href=\"/compare-scans\">Go back</a></p>",
8213 )
8214 .into_response();
8215 }
8216 if run_ids.len() > 20 {
8217 return Html(
8218 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
8219 at once. <a href=\"/compare-scans\">Go back</a></p>",
8220 )
8221 .into_response();
8222 }
8223
8224 let entries: Vec<Option<RegistryEntry>> = {
8226 let reg = state.registry.lock().await;
8227 run_ids
8228 .iter()
8229 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
8230 .collect()
8231 };
8232
8233 for (i, entry) in entries.iter().enumerate() {
8234 if entry.is_none() {
8235 let html = format!(
8236 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
8237 found. <a href=\"/compare-scans\">Go back</a></p>",
8238 run_ids[i]
8239 );
8240 return Html(html).into_response();
8241 }
8242 }
8243
8244 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
8245
8246 for entry in &entries {
8247 if entry.json_path.is_none() {
8248 let html = format!(
8249 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
8250 JSON data — re-run the analysis to enable comparison. \
8251 <a href=\"/compare-scans\">Go back</a></p>",
8252 &entry.run_id
8253 );
8254 return Html(html).into_response();
8255 }
8256 }
8257
8258 entries.sort_by_key(|e| e.timestamp_utc);
8260
8261 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
8263 for entry in &entries {
8264 let path = entry.json_path.as_ref().unwrap();
8265 match read_json(path) {
8266 Ok(r) => runs.push(r),
8267 Err(e) => {
8268 let html = format!(
8269 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
8270 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
8271 &entry.run_id
8272 );
8273 return Html(html).into_response();
8274 }
8275 }
8276 }
8277
8278 let all_sub_names: Vec<String> = {
8280 let mut set = std::collections::BTreeSet::new();
8281 for r in &runs {
8282 for s in &r.submodule_summaries {
8283 set.insert(s.name.clone());
8284 }
8285 }
8286 set.into_iter().collect()
8287 };
8288 let has_submodule_data = !all_sub_names.is_empty();
8289 let active_submodule = params.sub.clone();
8290 let super_scope_active = params.scope.as_deref() == Some("super");
8291
8292 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
8294
8295 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
8296 let project_label = entries
8297 .first()
8298 .map_or("", |e| e.project_label.as_str())
8299 .to_string();
8300 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
8301 let multi = compute_multi_delta(&run_refs);
8302 let html = multi_compare_page(
8303 &multi,
8304 &project_label,
8305 env!("CARGO_PKG_VERSION"),
8306 &csp_nonce,
8307 has_submodule_data,
8308 &all_sub_names,
8309 &runs_csv,
8310 super_scope_active,
8311 active_submodule.as_deref(),
8312 &entries,
8313 );
8314 (
8317 [(axum::http::header::CACHE_CONTROL, "no-store")],
8318 Html(html),
8319 )
8320 .into_response()
8321}
8322
8323const fn multi_delta_class(n: i64) -> &'static str {
8324 match n {
8325 1.. => "pos",
8326 ..=-1 => "neg",
8327 0 => "zero",
8328 }
8329}
8330
8331fn multi_fmt_delta(n: i64) -> String {
8332 if n > 0 {
8333 format!("+{n}")
8334 } else {
8335 format!("{n}")
8336 }
8337}
8338
8339fn js_escape(s: &str) -> String {
8341 use std::fmt::Write as _;
8342 let mut out = String::with_capacity(s.len() + 2);
8343 for c in s.chars() {
8344 match c {
8345 '"' => out.push_str("\\\""),
8346 '\\' => out.push_str("\\\\"),
8347 '\n' => out.push_str("\\n"),
8348 '\r' => out.push_str("\\r"),
8349 '\t' => out.push_str("\\t"),
8350 c if (c as u32) < 0x20 => {
8351 let _ = write!(out, "\\u{:04x}", c as u32);
8352 }
8353 c => out.push(c),
8354 }
8355 }
8356 out
8357}
8358
8359fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
8361 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
8362 return (
8363 "—".to_string(),
8364 "<span class=\"mc-row-val\">—</span>".to_string(),
8365 );
8366 };
8367 let cd = entry
8368 .git_commit_date
8369 .as_deref()
8370 .and_then(fmt_git_date)
8371 .unwrap_or_else(|| "—".to_string());
8372 let au = entry.git_author.as_deref().map_or_else(
8373 || "<span class=\"mc-row-val\">—</span>".to_string(),
8374 |a| {
8375 format!(
8376 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
8377 <span class=\"cmp-author-handle\"></span></span>",
8378 html_escape(a)
8379 )
8380 },
8381 );
8382 (cd, au)
8383}
8384
8385fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
8387 active_sub.map_or_else(
8388 || {
8389 if super_scope_active {
8390 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
8391 } else {
8392 "<span class=\"mc-scope-tag mc-scope-full\">\
8393 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
8394 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
8395 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
8396 <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>\
8397 </svg> Full scan</span>"
8398 .to_string()
8399 }
8400 },
8401 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
8402 )
8403}
8404
8405fn build_mc_scan_strip(
8407 multi: &MultiScanComparison,
8408 entries: &[RegistryEntry],
8409 n: usize,
8410 is_many: bool,
8411 active_sub: Option<&str>,
8412 super_scope_active: bool,
8413 project_label: &str,
8414) -> String {
8415 use std::fmt::Write as _;
8416 let mut scan_strip = String::new();
8417 for (i, pt) in multi.points.iter().enumerate() {
8418 let ts_ms = pt.timestamp.timestamp_millis();
8419 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8420 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
8421 let branch = pt.git_branch.as_deref().unwrap_or("");
8422 let report_link = format!("/runs/html/{}", pt.run_id);
8423 let branch_html = if branch.is_empty() {
8424 "<span class=\"mc-row-val\">—</span>".to_string()
8425 } else {
8426 format!(
8427 "<span class=\"mc-card-branch\">{}</span>",
8428 html_escape(branch)
8429 )
8430 };
8431 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
8432 let tags_html = pt
8433 .git_tags
8434 .as_deref()
8435 .filter(|t| !t.is_empty())
8436 .map(|t| {
8437 let chips = t
8438 .split(',')
8439 .filter(|s| !s.is_empty())
8440 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
8441 .collect::<Vec<_>>()
8442 .join(" ");
8443 format!(
8444 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
8445 <span class=\"mc-row-val\">{chips}</span></div>"
8446 )
8447 })
8448 .unwrap_or_default();
8449 let nearest = pt
8450 .git_nearest_tag
8451 .as_deref()
8452 .map(|t| format!("near {}", html_escape(t)))
8453 .unwrap_or_default();
8454 let arrow = if i < n - 1 && !is_many {
8455 "<div class='mc-arrow'>→</div>"
8456 } else {
8457 ""
8458 };
8459 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
8460 let nearest_html = if nearest.is_empty() {
8461 String::new()
8462 } else {
8463 format!(
8464 "<span class=\"mc-card-nearest-wrap\">\
8465 <span class=\"mc-card-nearest\">{nearest}</span>\
8466 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
8467 </span>"
8468 )
8469 };
8470 write!(
8471 scan_strip,
8472 r#"<div class="mc-card">
8473 <div class="mc-card-header">
8474 <div class="mc-card-num">Scan {num}</div>
8475 <div class="mc-card-project-col">
8476 <div class="mc-card-project">{project_label}</div>
8477 {scope_badge}
8478 </div>
8479 </div>
8480 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
8481 <div class="mc-card-rows">
8482 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
8483 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
8484 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
8485 <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>
8486 {tags_html}
8487 </div>
8488 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
8489 </div>{arrow}"#,
8490 num = i + 1,
8491 commit = html_escape(commit),
8492 commit_date = commit_date_html,
8493 ts_ms = ts_ms,
8494 code = fmt_num(pt.code_lines),
8495 scope_badge = scope_badge,
8496 nearest_html = nearest_html,
8497 )
8498 .unwrap();
8499 }
8500 scan_strip
8501}
8502
8503#[allow(clippy::too_many_lines)]
8505fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
8506 use std::fmt::Write as _;
8507 struct MetricRow<'a> {
8508 label: &'a str,
8509 values: Vec<i64>,
8510 seq_deltas: Vec<i64>,
8511 net_delta: i64,
8512 }
8513 let rows: Vec<MetricRow<'_>> = vec![
8514 MetricRow {
8515 label: "Code Lines",
8516 values: multi.points.iter().map(|p| p.code_lines).collect(),
8517 seq_deltas: multi
8518 .sequential_deltas
8519 .iter()
8520 .map(|d| d.summary.code_lines_delta)
8521 .collect(),
8522 net_delta: multi.total_delta.code_lines_delta,
8523 },
8524 MetricRow {
8525 label: "Files Analyzed",
8526 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
8527 seq_deltas: multi
8528 .sequential_deltas
8529 .iter()
8530 .map(|d| d.summary.files_analyzed_delta)
8531 .collect(),
8532 net_delta: multi.total_delta.files_analyzed_delta,
8533 },
8534 MetricRow {
8535 label: "Comment Lines",
8536 values: multi.points.iter().map(|p| p.comment_lines).collect(),
8537 seq_deltas: multi
8538 .sequential_deltas
8539 .iter()
8540 .map(|d| d.summary.comment_lines_delta)
8541 .collect(),
8542 net_delta: multi.total_delta.comment_lines_delta,
8543 },
8544 MetricRow {
8545 label: "Blank Lines",
8546 values: multi.points.iter().map(|p| p.blank_lines).collect(),
8547 seq_deltas: multi
8548 .sequential_deltas
8549 .iter()
8550 .map(|d| d.summary.blank_lines_delta)
8551 .collect(),
8552 net_delta: multi.total_delta.blank_lines_delta,
8553 },
8554 MetricRow {
8555 label: "Tests",
8556 values: multi.points.iter().map(|p| p.test_count).collect(),
8557 seq_deltas: multi
8558 .points
8559 .windows(2)
8560 .map(|pts| pts[1].test_count - pts[0].test_count)
8561 .collect(),
8562 net_delta: multi.points.last().map_or(0, |l| l.test_count)
8563 - multi.points.first().map_or(0, |f| f.test_count),
8564 },
8565 ];
8566 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
8567 for i in 0..n {
8568 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
8569 if i < n - 1 {
8570 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
8571 }
8572 }
8573 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
8574 let mut metrics_tbody = String::new();
8575 for row in &rows {
8576 metrics_tbody.push_str("<tr>");
8577 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
8578 for i in 0..n {
8579 write!(
8580 metrics_tbody,
8581 "<td class='mc-val-col'>{}</td>",
8582 fmt_comma(row.values[i])
8583 )
8584 .unwrap();
8585 if i < n - 1 {
8586 let d = row.seq_deltas[i];
8587 write!(
8588 metrics_tbody,
8589 "<td class='mc-delta-col {cls}'>{val}</td>",
8590 cls = multi_delta_class(d),
8591 val = multi_fmt_delta(d)
8592 )
8593 .unwrap();
8594 }
8595 }
8596 let nd = row.net_delta;
8597 write!(
8598 metrics_tbody,
8599 "<td class='mc-net-col {cls}'>{val}</td>",
8600 cls = multi_delta_class(nd),
8601 val = multi_fmt_delta(nd)
8602 )
8603 .unwrap();
8604 metrics_tbody.push_str("</tr>");
8605 }
8606 (metrics_thead, metrics_tbody)
8607}
8608
8609fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
8611 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
8612 for (i, pt) in multi.points.iter().enumerate() {
8613 let commit = pt.git_commit.as_deref().unwrap_or("");
8614 let branch = pt.git_branch.as_deref().unwrap_or("");
8615 let tags = pt.git_tags.as_deref().unwrap_or("");
8616 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
8617 let scanned_ms = pt.timestamp.timestamp_millis();
8618 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
8619 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
8620 let commit_date = entry
8621 .and_then(|e| e.git_commit_date.as_deref())
8622 .and_then(fmt_git_date)
8623 .unwrap_or_default();
8624 let author = entry
8625 .and_then(|e| e.git_author.as_deref())
8626 .unwrap_or("")
8627 .to_string();
8628 let cov = pt
8629 .coverage_line_pct
8630 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
8631 parts.push(format!(
8632 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}}}"#,
8633 run_id = js_escape(&pt.run_id),
8634 commit = js_escape(commit),
8635 branch = js_escape(branch),
8636 tags = js_escape(tags),
8637 nearest = js_escape(nearest),
8638 commit_date = js_escape(&commit_date),
8639 author = js_escape(&author),
8640 scanned = js_escape(&scanned),
8641 code = pt.code_lines,
8642 comments = pt.comment_lines,
8643 blank = pt.blank_lines,
8644 files = pt.files_analyzed,
8645 tests = pt.test_count,
8646 ));
8647 }
8648 format!("[{}]", parts.join(","))
8649}
8650
8651fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
8653 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
8654 for row in &multi.file_matrix {
8655 let lang = row.language.as_deref().unwrap_or("");
8656 let codes: Vec<String> = row
8657 .code_per_scan
8658 .iter()
8659 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8660 .collect();
8661 let deltas: Vec<String> = row
8662 .code_delta_per_scan
8663 .iter()
8664 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
8665 .collect();
8666 parts.push(format!(
8667 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
8668 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
8669 status = row.overall_status,
8670 codes = codes.join(","),
8671 deltas = deltas.join(","),
8672 total = row.total_code_delta,
8673 ));
8674 }
8675 format!("[{}]", parts.join(","))
8676}
8677
8678fn build_mc_file_col_headers(n: usize) -> String {
8680 use std::fmt::Write as _;
8681 let mut out = String::new();
8682 for i in 0..n {
8683 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
8684 if i < n - 1 {
8685 write!(
8686 out,
8687 "<th class='file-delta-col'>Δ→{}</th>",
8688 i + 2
8689 )
8690 .unwrap();
8691 }
8692 }
8693 out
8694}
8695
8696fn build_mc_scope_bar(
8698 has_submodule_data: bool,
8699 sub_names: &[String],
8700 runs_csv: &str,
8701 active_sub: Option<&str>,
8702 super_scope_active: bool,
8703) -> String {
8704 use std::fmt::Write as _;
8705 if !has_submodule_data {
8706 return String::new();
8707 }
8708 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
8709 let full_active = active_sub.is_none() && !super_scope_active;
8710 let mut bar = format!(
8711 r#"<div class="submod-scope-bar">
8712 <span class="submod-scope-label">
8713 <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>
8714 Scope:
8715 </span>
8716 <div class="submod-scope-divider"></div>
8717 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
8718 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
8719 full_cls = if full_active { " active" } else { "" },
8720 super_cls = if super_scope_active { " active" } else { "" },
8721 );
8722 for s in sub_names {
8723 let is_active = active_sub == Some(s.as_str());
8724 write!(
8725 bar,
8726 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
8727 cls = if is_active { " active" } else { "" },
8728 name_enc = html_escape(s),
8729 name_esc = html_escape(s),
8730 )
8731 .unwrap();
8732 }
8733 bar.push_str("\n</div>");
8734 bar
8735}
8736
8737fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
8739 active_sub.map_or_else(
8740 || {
8741 if super_scope_active {
8742 "Super-repo only — ".to_string()
8743 } else {
8744 String::new()
8745 }
8746 },
8747 |s| format!("Submodule: {} — ", html_escape(s)),
8748 )
8749}
8750
8751#[allow(clippy::too_many_lines)]
8752#[allow(clippy::too_many_arguments)]
8753fn multi_compare_page(
8754 multi: &MultiScanComparison,
8755 project_label: &str,
8756 version: &str,
8757 csp_nonce: &str,
8758 has_submodule_data: bool,
8759 sub_names: &[String],
8760 runs_csv: &str,
8761 super_scope_active: bool,
8762 active_sub: Option<&str>,
8763 entries: &[RegistryEntry],
8764) -> String {
8765 let n = multi.points.len();
8766 let is_many = n > 4;
8767 let mc_strip_class = if is_many {
8768 "mc-strip mc-strip-grid"
8769 } else {
8770 "mc-strip"
8771 };
8772
8773 let scan_strip = build_mc_scan_strip(
8775 multi,
8776 entries,
8777 n,
8778 is_many,
8779 active_sub,
8780 super_scope_active,
8781 project_label,
8782 );
8783
8784 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
8786
8787 let points_json = build_mc_points_json(multi, entries);
8789 let file_matrix_json = build_mc_file_matrix_json(multi);
8790
8791 let files_modified = multi
8793 .file_matrix
8794 .iter()
8795 .filter(|f| f.overall_status == "modified")
8796 .count();
8797 let files_added = multi
8798 .file_matrix
8799 .iter()
8800 .filter(|f| f.overall_status == "added")
8801 .count();
8802 let files_removed = multi
8803 .file_matrix
8804 .iter()
8805 .filter(|f| f.overall_status == "removed")
8806 .count();
8807 let files_unchanged = multi
8808 .file_matrix
8809 .iter()
8810 .filter(|f| f.overall_status == "unchanged")
8811 .count();
8812 let total_files = multi.file_matrix.len();
8813
8814 let file_col_headers = build_mc_file_col_headers(n);
8815 let nav_compare_active = "";
8816 let scope_bar_html = build_mc_scope_bar(
8817 has_submodule_data,
8818 sub_names,
8819 runs_csv,
8820 active_sub,
8821 super_scope_active,
8822 );
8823 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
8824
8825 format!(
8826 r##"<!doctype html>
8827<html lang="en">
8828<head>
8829 <meta charset="utf-8">
8830 <meta name="viewport" content="width=device-width, initial-scale=1">
8831 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
8832 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
8833 <style nonce="{csp_nonce}">
8834 :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;}}
8835 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
8836 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
8837 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;}}
8838 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8839 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
8840 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
8841 .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;}}
8842 @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));}}}}
8843 .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);}}
8844 .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;}}
8845 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
8846 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
8847 @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;}}}}
8848 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
8849 .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));}}
8850 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
8851 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
8852 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
8853 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
8854 .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;}}
8855 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8856 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
8857 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
8858 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
8859 .nav-dropdown{{position:relative;display:inline-flex;}}
8860 .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;}}
8861 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
8862 .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;}}
8863 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
8864 .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);}}
8865 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
8866 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
8867 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
8868 body:not(.dark-theme) .icon-sun{{display:none;}}
8869 body.dark-theme .icon-moon{{display:none;}}
8870 .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;}}
8871 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
8872 .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);}}
8873 .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;}}
8874 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
8875 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
8876 .settings-modal-body{{padding:14px 16px 16px;}}
8877 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
8878 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
8879 .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;}}
8880 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
8881 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
8882 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
8883 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
8884 .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;}}
8885 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
8886 .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;}}
8887 .btn-back:hover{{background:var(--line);}}
8888 .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;}}
8889 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;}}
8890 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
8891 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
8892 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
8893 .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;}}
8894 .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;}}
8895 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
8896 .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;}}
8897 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
8898 body.dark-theme .mc-card{{background:var(--surface-2);}}
8899 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
8900 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
8901 .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%;}}
8902 .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;}}
8903 .mc-card-commit:hover{{color:var(--oxide);}}
8904 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
8905 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
8906 .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;}}
8907 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
8908 .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;}}
8909 .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;}}
8910 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
8911 .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;}}
8912 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
8913 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
8914 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
8915 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
8916 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
8917 .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);}}
8918 .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);}}
8919 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
8920 .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;}}
8921 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
8922 .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;}}
8923 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
8924 .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;}}
8925 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
8926 .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;}}
8927 .submod-scope-btn:hover{{background:var(--line);}}
8928 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8929 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
8930 .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;}}
8931 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
8932 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
8933 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
8934 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
8935 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
8936 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
8937 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
8938 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
8939 .metrics-table .pos{{color:var(--pos);}}
8940 .metrics-table .neg{{color:var(--neg);}}
8941 .metrics-table .zero{{color:var(--muted);}}
8942 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
8943 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8944 .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;}}
8945 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8946 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
8947 .chart-wrap{{width:100%;overflow-x:auto;}}
8948 #mc-chart{{display:block;width:100%;}}
8949 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
8950 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
8951 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
8952 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
8953 .ic-card{{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}}
8954 body.dark-theme .ic-card{{border-color:var(--line-strong);}}
8955 .ic-card-h2{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}}
8956 .ic-card-h2-row{{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}}
8957 .ic-card-h2-row .ic-card-h2{{margin:0;}}
8958 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
8959 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
8960 .ic-cb{{cursor:pointer;transition:filter .15s;}}
8961 .ic-cb:hover{{filter:brightness(1.12);}}
8962 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
8963 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
8964 #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;}}
8965 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
8966 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
8967 .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;}}
8968 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
8969 .tab-btn:hover:not(.active){{background:var(--line);}}
8970 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
8971 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
8972 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
8973 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
8974 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
8975 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
8976 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
8977 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
8978 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
8979 .table-wrap{{width:100%;overflow-x:auto;}}
8980 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
8981 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
8982 #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;}}
8983 #file-table th.left,#file-table td.left{{text-align:left;}}
8984 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
8985 .file-delta-col{{color:var(--muted);font-size:11px;}}
8986 .file-net-col{{font-weight:800;}}
8987 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
8988 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
8989 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
8990 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
8991 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
8992 .status-badge.modified{{background:#fff2d8;color:#926000;}}
8993 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
8994 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
8995 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
8996 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
8997 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
8998 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
8999 tr.row-added td{{background:rgba(26,143,71,0.04);}}
9000 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9001 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9002 tr.row-unchanged td{{color:var(--muted);}}
9003 tr.row-unchanged .status-badge{{opacity:.65;}}
9004 .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;}}
9005 .absent{{color:var(--muted);font-style:italic;}}
9006 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9007 .pagination-info{{font-size:12px;color:var(--muted);}}
9008 .pagination-btns{{display:flex;gap:5px;}}
9009 .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;}}
9010 .pg-btn:hover:not(:disabled){{background:var(--line);}}
9011 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9012 .pg-btn:disabled{{opacity:.35;cursor:default;}}
9013 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;}}
9014 .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;}}
9015 .export-btn:hover{{background:var(--line);}}
9016 .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;}}
9017 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9018 .site-footer a{{color:var(--muted);}}
9019 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;}}
9020 body.pdf-mode{{background:#fff!important;}}
9021 .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;}}
9022 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9023 .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;}}
9024 .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;}}
9025 .mc-modal-title{{font-size:18px;font-weight:800;}}
9026 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9027 .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;}}
9028 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
9029 .mc-modal-body{{padding:18px 22px;}}
9030 .mc-modal-sec{{margin-bottom:20px;}}
9031 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
9032 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
9033 .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;}}
9034 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
9035 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
9036 .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;}}
9037 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
9038 .mc-modal-row:last-child{{border-bottom:none;}}
9039 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
9040 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
9041 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
9042 .mc-modal-val a:hover{{text-decoration:underline;}}
9043 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
9044 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
9045 .mc-modal-stat[data-tip]{{cursor:help;}}
9046 #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);}}
9047 .mc-card{{cursor:pointer;}}
9048 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
9049 </style>
9050</head>
9051<body>
9052 <div class="background-watermarks" aria-hidden="true">
9053 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9054 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9055 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9056 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9057 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9058 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
9059 </div>
9060 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9061 <div class="top-nav">
9062 <div class="top-nav-inner">
9063 <a class="brand" href="/">
9064 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
9065 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
9066 </a>
9067 <div class="nav-right">
9068 <a class="nav-pill" href="/">Home</a>
9069 <div class="nav-dropdown">
9070 <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>
9071 <div class="nav-dropdown-menu">
9072 <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>
9073 </div>
9074 </div>
9075 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
9076 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9077 <div class="nav-dropdown">
9078 <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>
9079 <div class="nav-dropdown-menu">
9080 <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>
9081 </div>
9082 </div>
9083 <div class="server-status-wrap" id="server-status-wrap">
9084 <div class="nav-pill server-online-pill" id="server-status-pill">
9085 <span class="status-dot" id="status-dot"></span>
9086 <span id="server-status-label">Server</span>
9087 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
9088 </div>
9089 <div class="server-status-tip">
9090 OxideSLOC is running — accessible on your network.
9091 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
9092 </div>
9093 </div>
9094 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9095 <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>
9096 </button>
9097 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
9098 <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>
9099 <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>
9100 </button>
9101 </div>
9102 </div>
9103 </div>
9104
9105 <div class="page">
9106 <!-- Hero header -->
9107 <div class="mc-hero">
9108 <div class="mc-hero-header">
9109 <div>
9110 <div class="mc-title">Multi-Scan Timeline</div>
9111 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
9112 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
9113 </div>
9114 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9115 <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>
9116 <div class="export-group" id="mc-top-export-group">
9117 <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>
9118 <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>
9119 </div>
9120 </div>
9121 </div>
9122 {scope_bar_html}
9123 <!-- Scan strip -->
9124 <div class="{mc_strip_class}">{scan_strip}</div>
9125 </div>
9126
9127 <!-- Summary metrics table -->
9128 <div class="panel">
9129 <div class="panel-title">Metric Progression</div>
9130 <div class="table-wrap">
9131 <table class="metrics-table">
9132 <thead>{metrics_thead}</thead>
9133 <tbody>{metrics_tbody}</tbody>
9134 </table>
9135 </div>
9136 </div>
9137
9138 <!-- Scan Charts -->
9139 <div class="panel" id="mc-charts-panel">
9140 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
9141 <div class="ic-grid">
9142 <!-- Timeline line chart — spans full width -->
9143 <div class="ic-card" style="grid-column:span 2">
9144 <div class="ic-card-h2-row">
9145 <span class="ic-card-h2">Timeline</span>
9146 <div class="chart-toolbar" style="margin:0">
9147 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
9148 <button class="chart-metric-btn" data-metric="files">Files</button>
9149 <button class="chart-metric-btn" data-metric="comments">Comments</button>
9150 <button class="chart-metric-btn" data-metric="tests">Tests</button>
9151 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
9152 </div>
9153 </div>
9154 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
9155 </div>
9156 <!-- Code Metrics: Scan 1 vs Latest -->
9157 <div class="ic-card">
9158 <div class="ic-card-h2">Code Metrics — Scan 1 vs Latest</div>
9159 <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>
9160 <div id="mc-ic-c1"></div>
9161 </div>
9162 <!-- Language Code Delta -->
9163 <div class="ic-card" id="mc-ic-lang-card">
9164 <div class="ic-card-h2">Language Code Delta</div>
9165 <div id="mc-ic-c3"></div>
9166 </div>
9167 <!-- Delta by Metric -->
9168 <div class="ic-card">
9169 <div class="ic-card-h2">Delta by Metric</div>
9170 <div id="mc-ic-c2"></div>
9171 </div>
9172 <!-- File Change Distribution -->
9173 <div class="ic-card">
9174 <div class="ic-card-h2">File Change Distribution</div>
9175 <div id="mc-ic-c4"></div>
9176 </div>
9177 </div>
9178 </div>
9179
9180 <!-- File matrix table -->
9181 <div class="panel">
9182 <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>
9183 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
9184 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
9185 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
9186 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
9187 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
9188 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
9189 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
9190 </div>
9191 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
9192 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
9193 <div class="export-group">
9194 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
9195 <button type="button" class="export-btn" id="export-csv-btn">
9196 <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>
9197 CSV
9198 </button>
9199 <button type="button" class="export-btn" id="mc-file-xls-btn">
9200 <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>
9201 Excel
9202 </button>
9203 </div>
9204 </div>
9205 </div>
9206 <div class="table-wrap">
9207 <table id="file-table">
9208 <thead>
9209 <tr>
9210 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
9211 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
9212 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
9213 {file_col_headers}
9214 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
9215 </tr>
9216 </thead>
9217 <tbody id="file-tbody"></tbody>
9218 </table>
9219 </div>
9220 <div class="pagination">
9221 <span class="pagination-info" id="pg-info"></span>
9222 <div class="pagination-btns" id="pg-btns"></div>
9223 <div style="display:flex;align-items:center;gap:6px;">
9224 <span style="font-size:12px;color:var(--muted)">Show</span>
9225 <select class="per-page" id="per-page-sel">
9226 <option value="25" selected>25 per page</option>
9227 <option value="50">50 per page</option>
9228 <option value="100">100 per page</option>
9229 </select>
9230 </div>
9231 </div>
9232 </div>
9233 </div>
9234
9235 <div id="mc-ic-tt"></div>
9236
9237 <footer class="site-footer">
9238 oxide-sloc v{version} — local code metrics workbench ·
9239 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
9240 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
9241 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
9242 · <a href="/api-docs" rel="noopener">REST API</a>
9243 </footer>
9244
9245 <script nonce="{csp_nonce}">
9246 (function(){{
9247 // ── Dark theme ───────────────────────────────────────────────────────────
9248 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
9249 var tt=document.getElementById('theme-toggle');
9250 if(tt)tt.addEventListener('click',function(){{
9251 var on=document.body.classList.toggle('dark-theme');
9252 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
9253 renderChart(activeMetric);
9254 }});
9255
9256 // ── Code particles ───────────────────────────────────────────────────────
9257 var container=document.getElementById('code-particles');
9258 if(container){{
9259 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()'];
9260 for(var i=0;i<28;i++){{
9261 (function(idx){{
9262 var el=document.createElement('span');el.className='code-particle';
9263 el.textContent=snips[idx%snips.length];
9264 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
9265 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
9266 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
9267 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
9268 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
9269 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
9270 container.appendChild(el);
9271 }})(i);
9272 }}
9273 }}
9274
9275 // ── Watermarks ───────────────────────────────────────────────────────────
9276 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
9277 if(wms.length){{
9278 var placed=[];
9279 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;}}
9280 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];}}
9281 var half=Math.floor(wms.length/2);
9282 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;}});
9283 }}
9284
9285 // ── Settings / colour scheme modal ───────────────────────────────────────
9286 (function(){{
9287 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'}}];
9288 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);}});}}
9289 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
9290 function init(){{
9291 var btn=document.getElementById('settings-btn');if(!btn)return;
9292 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9293 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>';
9294 document.body.appendChild(m);
9295 var g=document.getElementById('scheme-grid');
9296 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);}});
9297 var cl=document.getElementById('settings-close-btn');
9298 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');}});
9299 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
9300 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
9301 }}
9302 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9303 }})();
9304
9305 // ── Timezone support for scan timestamps ─────────────────────────────────
9306 (function(){{
9307 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';}};
9308 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'';}}}};
9309 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);}});}};
9310 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
9311 window.applyTz(storedTz);
9312 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
9313 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
9314 }})();
9315
9316 // ── Data ────────────────────────────────────────────────────────────────
9317 var POINTS={points_json};
9318 var FILES={file_matrix_json};
9319 var N={n};
9320
9321 // ── fmt helper ───────────────────────────────────────────────────────────
9322 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();}}
9323 function fmtFull(n){{return Number(n).toLocaleString();}}
9324 function fmtDelta(n){{return n>0?'+'+fmt(n):fmt(n);}}
9325
9326 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
9327 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
9328 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));}}
9329 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
9330 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
9331
9332 // ── Timeline chart ───────────────────────────────────────────────────────
9333 var activeMetric='code';
9334 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
9335 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
9336
9337 function renderChart(metric){{
9338 var svg=document.getElementById('mc-chart');if(!svg)return;
9339 var W=svg.getBoundingClientRect().width||800,H=280;
9340 svg.setAttribute('height',H);
9341 var pad={{l:62,r:20,t:32,b:72}};
9342 var dark=document.body.classList.contains('dark-theme');
9343 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
9344 var valid=pts.filter(function(v){{return v!=null;}});
9345 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;}}
9346 var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
9347 if(minV===maxV){{minV=Math.max(0,minV-1);maxV=maxV+1;}}
9348 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
9349 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
9350 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
9351 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
9352 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
9353 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
9354 var parts=[];
9355 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
9356 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>');}}
9357 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
9358 var lineD='';var firstPt=true;
9359 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);}}}}
9360 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
9361 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
9362 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
9363 for(var i=0;i<N;i++){{
9364 if(pts[i]==null)continue;
9365 var cx=xOf(i),cy=yOf(pts[i]);
9366 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
9367 var hasTag=p.tags&&p.tags.length>0;
9368 // Permanent Y-value label above the dot
9369 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>');
9370 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+'\'"/>');
9371 var xanchor=i===0?'start':i===N-1?'end':'middle';
9372 // X-axis label at 2× the original size (18 px)
9373 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>');
9374 }}
9375 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
9376 svg.setAttribute('viewBox','0 0 '+W+' '+H);
9377 svg.innerHTML=parts.join('');
9378 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
9379 svg.onmousemove=function(e){{
9380 var rect=svg.getBoundingClientRect();
9381 var scaleX=W/rect.width;
9382 var mouseX=(e.clientX-rect.left)*scaleX;
9383 var nearest=-1,minDist=Infinity;
9384 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;}}}}
9385 if(nearest<0)return;
9386 var nc=xOf(nearest),ny=yOf(pts[nearest]);
9387 var xhair=svg.querySelector('.mc-xhair');
9388 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
9389 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"/>';
9390 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
9391 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
9392 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>';
9393 var bx=rect.left+(nc/W*rect.width)+18;
9394 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
9395 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
9396 }};
9397 svg.onmouseleave=function(){{
9398 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
9399 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
9400 }};
9401 }}
9402
9403 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9404
9405 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
9406 btn.addEventListener('click',function(){{
9407 activeMetric=this.dataset.metric;
9408 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
9409 this.classList.add('active');
9410 renderChart(activeMetric);
9411 }});
9412 }});
9413 if(typeof ResizeObserver!=='undefined'){{
9414 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
9415 }}
9416 renderChart(activeMetric);
9417
9418 // ── File matrix table ────────────────────────────────────────────────────
9419 var activeStatus='';
9420 var currentPage=1;
9421 var perPage=25;
9422 var mcSortCol=null,mcSortAsc=true;
9423
9424 function getFiltered(){{
9425 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
9426 if(!mcSortCol)return data;
9427 var asc=mcSortAsc;
9428 return data.slice().sort(function(a,b){{
9429 var va,vb;
9430 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
9431 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
9432 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
9433 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
9434 else{{return 0;}}
9435 if(asc)return va<vb?-1:va>vb?1:0;
9436 return va<vb?1:va>vb?-1:0;
9437 }});
9438 }}
9439
9440 function renderFilePage(){{
9441 var filtered=getFiltered();
9442 var total=filtered.length;
9443 var totalPages=Math.max(1,Math.ceil(total/perPage));
9444 if(currentPage>totalPages)currentPage=totalPages;
9445 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
9446 var tbody=document.getElementById('file-tbody');if(!tbody)return;
9447 var rows=[];
9448 for(var i=start;i<end;i++){{
9449 var f=filtered[i];
9450 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
9451 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
9452 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
9453 for(var j=0;j<N;j++){{
9454 var cv=f.c[j];
9455 cells+='<td class="file-scan-col">'+(cv!=null?fmt(cv):'<span class="absent">\u2014</span>')+'</td>';
9456 if(j<N-1){{
9457 var dv=f.d[j+1];
9458 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
9459 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
9460 }}
9461 }}
9462 var tc=f.t;
9463 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
9464 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
9465 }}
9466 tbody.innerHTML=rows.join('');
9467
9468 var info=document.getElementById('pg-info');
9469 if(info)info.textContent='Showing '+(total?start+1:0)+'–'+end+' of '+total+' files';
9470 renderPgBtns(totalPages);
9471 }}
9472
9473 function renderPgBtns(totalPages){{
9474 var wrap=document.getElementById('pg-btns');if(!wrap)return;
9475 var btns=[];
9476 function mkBtn(label,page,active,disabled){{
9477 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
9478 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
9479 }}
9480 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
9481 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
9482 if(s>1)btns.push(mkBtn('1',1,false,false));
9483 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
9484 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
9485 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
9486 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
9487 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
9488 wrap.innerHTML=btns.join('');
9489 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
9490 b.addEventListener('click',function(){{
9491 var pg=parseInt(this.dataset.pg,10);
9492 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
9493 }});
9494 }});
9495 }}
9496
9497 // Tab filter
9498 document.querySelectorAll('.tab-btn').forEach(function(btn){{
9499 btn.addEventListener('click',function(){{
9500 activeStatus=this.dataset.status||'';
9501 currentPage=1;
9502 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9503 this.classList.add('active');
9504 renderFilePage();
9505 }});
9506 }});
9507
9508 // Per-page selector
9509 var ppSel=document.getElementById('per-page-sel');
9510 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
9511
9512 // ── Column header sort ───────────────────────────────────────────────────
9513 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
9514 th.addEventListener('click',function(){{
9515 var col=th.dataset.sortCol;
9516 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
9517 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9518 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
9519 }});
9520 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
9521 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
9522 currentPage=1;renderFilePage();
9523 }});
9524 }});
9525
9526 // Reset button also clears sort
9527 var mcResetBtn=document.getElementById('mc-file-reset-btn');
9528 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
9529 mcSortCol=null;mcSortAsc=true;
9530 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
9531 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
9532 }});
9533 activeStatus='';currentPage=1;
9534 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9535 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
9536 renderFilePage();
9537 }});
9538
9539 renderFilePage();
9540
9541 // ── CSV export ───────────────────────────────────────────────────────────
9542 var exportBtn=document.getElementById('export-csv-btn');
9543 if(exportBtn)exportBtn.addEventListener('click',function(){{
9544 var header=['File','Language','Status'];
9545 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
9546 header.push('Net Delta');
9547 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
9548 var filtered=getFiltered();
9549 filtered.forEach(function(f){{
9550 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
9551 for(var j=0;j<N;j++){{
9552 cols.push(f.c[j]!=null?f.c[j]:'');
9553 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
9554 }}
9555 cols.push(f.t);
9556 rows.push(cols.join(','));
9557 }});
9558 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
9559 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9560 a.download=mcExportName('csv');a.click();
9561 }});
9562
9563 // ── File matrix extra export buttons ─────────────────────────────────────
9564 (function(){{
9565 var resetBtn=document.getElementById('mc-file-reset-btn');
9566 if(resetBtn)resetBtn.addEventListener('click',function(){{
9567 activeStatus='';currentPage=1;
9568 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
9569 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
9570 renderFilePage();
9571 }});
9572
9573 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
9574 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
9575 function mcMakeXlsx(fname){{
9576 var filtered=getFiltered();
9577 var enc=new TextEncoder();
9578 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;}}
9579 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;}}
9580 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
9581 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
9582 var ss=[],si={{}};
9583 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
9584 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9585 function WS(){{
9586 var R=0,buf=[];
9587 function cl(c){{return String.fromCharCode(65+c);}}
9588 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
9589 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
9590 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
9591 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>';}}
9592 return{{sc:sc,nc:nc,row:row,xml:xml}};
9593 }}
9594 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
9595 var proj=mcExportProj();
9596 // \u2500\u2500 Summary sheet \u2500\u2500
9597 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
9598 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
9599 r1(s1(0,proj,2));
9600 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
9601 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
9602 r1('');
9603 r1(s1(0,'SCAN SUMMARY',8));
9604 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));
9605 POINTS.forEach(function(p,i){{
9606 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
9607 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));
9608 }});
9609 r1('');
9610 if(POINTS.length>1){{
9611 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
9612 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
9613 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
9614 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)));}};
9615 nr('Code Lines',pf.code,pl.code);
9616 nr('Comment Lines',pf.comments,pl.comments);
9617 nr('Files Analyzed',pf.files,pl.files);
9618 nr('Tests',pf.tests,pl.tests);
9619 r1('');
9620 }}
9621 var cMod=0,cAdd=0,cRem=0,cUnch=0;
9622 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
9623 var totF=FILES.length||1;
9624 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
9625 r1(s1(0,'FILE CHANGES',8));
9626 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
9627 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
9628 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
9629 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
9630 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
9631 var lm={{}};
9632 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;}});
9633 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
9634 if(langs.length){{
9635 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
9636 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
9637 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)));}});
9638 }}
9639 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
9640 // \u2500\u2500 File Delta sheet \u2500\u2500
9641 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
9642 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
9643 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);}}
9644 hcells+=s2(hc,'Net Delta',3);
9645 r2(hcells);
9646 filtered.forEach(function(f){{
9647 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
9648 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));}}}}
9649 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
9650 r2(cells);
9651 }});
9652 var ncols=3+N+(N-1)+1;
9653 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
9654 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>';
9655 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
9656 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>',
9657 '_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>',
9658 '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>',
9659 '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>',
9660 '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>',
9661 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
9662 var zparts=[],zcds=[],zoff=0,znf=0;
9663 ['[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){{
9664 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
9665 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]);
9666 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);
9667 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));
9668 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
9669 zoff+=entry.length;znf++;
9670 }});
9671 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
9672 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]);
9673 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
9674 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9675 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
9676 out.set(new Uint8Array(eocd),pos);
9677 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
9678 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9679 }}
9680
9681 var xlsBtn=document.getElementById('mc-file-xls-btn');
9682 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
9683
9684 // File matrix HTML export — interactive: sort by column, filter by status
9685 function mcFileBuildHtml(){{
9686 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9687 var hdrs=['File','Language','Status'];
9688 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
9689 hdrs.push('Net \u0394');
9690 var SI=2;
9691 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;}});
9692 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
9693 var cnt={{all:allRows.length}};
9694 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
9695 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
9696 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
9697 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9698 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9699 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
9700 '.sub{{font-size:12px;color:#99aabb;}}'+
9701 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
9702 '.wr{{padding:16px 20px;}}'+
9703 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
9704 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
9705 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
9706 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
9707 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
9708 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
9709 'thead tr{{background:#1a2035;}}'+
9710 '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;}}'+
9711 'th:hover{{background:#2a3050;}}'+
9712 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
9713 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
9714 'tr:nth-child(even) td{{background:#faf7f4;}}'+
9715 'tr:hover td{{background:#f5f0ea;}}'+
9716 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
9717 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
9718 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
9719 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
9720 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
9721 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
9722 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
9723 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
9724 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
9725 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
9726 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
9727 '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>";}}'+
9728 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
9729 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
9730 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
9731 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
9732 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
9733 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
9734 '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("")'+
9735 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
9736 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
9737 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
9738 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
9739 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
9740 'sd=(sc===ci)?-sd:1;sc=ci;'+
9741 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
9742 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
9743 'render();';
9744 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
9745 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
9746 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
9747 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
9748 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
9749 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
9750 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
9751 '<script>'+inlineJs+'<\/script><\/body><\/html>';
9752 }}
9753
9754 var htmlBtn=document.getElementById('mc-file-html-btn');
9755 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
9756 var h=mcFileBuildHtml();
9757 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
9758 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9759 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9760 }});
9761
9762 var pdfBtn=document.getElementById('mc-file-pdf-btn');
9763 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
9764 var btn=pdfBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
9765 var h=mcBuildPdfHtml();
9766 fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:h,filename:mcExportName('files.pdf')}})}})
9767 .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
9768 .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);}})
9769 .catch(function(e){{alert('PDF export failed: '+e.message);}})
9770 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
9771 }});
9772 }})();
9773
9774 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
9775 (function(){{
9776 var OX='#C45C10',GN='#2A6846',RD='#B23030',LGY='#DDDDDD';
9777 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9778 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();}}
9779 function px(n){{return Math.round(n);}}
9780 var _tt=document.getElementById('mc-ic-tt');
9781 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
9782 function addTT(el){{
9783 if(!el)return;
9784 el.addEventListener('mouseover',function(e){{
9785 var t=e.target.closest('[data-ttl]');
9786 if(t&&_tt){{
9787 var ttl=t.getAttribute('data-ttl');
9788 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
9789 _tt.style.display='block';mvTT(e);
9790 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9791 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
9792 }} else {{
9793 if(_tt)_tt.style.display='none';
9794 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9795 }}
9796 }});
9797 el.addEventListener('mouseleave',function(){{
9798 if(_tt)_tt.style.display='none';
9799 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9800 }});
9801 el.addEventListener('mousemove',function(e){{mvTT(e);}});
9802 }}
9803 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';}}
9804 if(N<2)return;
9805 var p0=POINTS[0],pLast=POINTS[N-1];
9806 // Chart 1: Code Metrics — Scan 1 vs Latest (grouped bars, same structure as Scan Delta)
9807 var c1mets=[
9808 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:'#93C5FD',cc:'#2563EB'}},
9809 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:'#C4B5FD',cc:'#7C3AED'}},
9810 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:'#6EE7B7',cc:'#0D9488'}}
9811 ];
9812 var maxV1=Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))*1.15||1;
9813 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;
9814 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9815 for(var gi=1;gi<=4;gi++){{
9816 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
9817 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
9818 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>';
9819 }}
9820 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
9821 c1+='<text x="'+(c1ml-5)+'" y="'+(c1mt+c1ph+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">0</text>';
9822 c1mets.forEach(function(m,i){{
9823 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
9824 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
9825 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>';
9826 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;"/>';
9827 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>';
9828 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;"/>';
9829 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>';
9830 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>';
9831 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>';
9832 }});
9833 c1+='</svg>';
9834 // Chart 2: Delta by Metric (net delta first scan to last)
9835 var mets=[
9836 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#2563EB'}},
9837 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#7C3AED'}},
9838 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:'#0D9488'}}
9839 ];
9840 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
9841 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;
9842 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9843 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9844 mets.forEach(function(m,i){{
9845 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);
9846 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>';
9847 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;"/>';
9848 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>';}}
9849 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>';}}
9850 }});
9851 c2+='</svg>';
9852 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
9853 var lm={{}};
9854 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;}});
9855 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
9856 var c3='';
9857 if(langs.length){{
9858 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
9859 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;
9860 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
9861 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
9862 langs.forEach(function(l,i){{
9863 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);
9864 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
9865 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"/>';
9866 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>';}}
9867 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>';}}
9868 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>';
9869 }});
9870 c3+='</svg>';
9871 }}
9872 // Chart 4: File Change Distribution (centered donut, legend below)
9873 var fm=0,fa=0,fr=0,fu=0;
9874 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
9875 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;}});
9876 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
9877 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
9878 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;
9879 if(segs.length===1){{
9880 c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
9881 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface-2)"/>';
9882 }} else {{
9883 segs.forEach(function(s){{
9884 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
9885 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);
9886 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);
9887 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"/>';
9888 ang4+=sw;
9889 }});
9890 }}
9891 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>';
9892 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
9893 segs.forEach(function(s,i){{
9894 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
9895 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;"/>';
9896 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>';
9897 }});
9898 c4+='</svg>';
9899 // Inject charts
9900 var e1=document.getElementById('mc-ic-c1');if(e1){{e1.innerHTML=c1;addTT(e1);}}
9901 var e2=document.getElementById('mc-ic-c2');if(e2){{e2.innerHTML=c2;addTT(e2);}}
9902 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);}}
9903 var e4=document.getElementById('mc-ic-c4');if(e4){{e4.innerHTML=c4;addTT(e4);}}
9904 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
9905
9906 // HTML legend hover → highlight matching SVG bars within the SAME card only
9907 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
9908 var metric=leg.getAttribute('data-highlight');
9909 var parentCard=leg.closest('.ic-card');
9910 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
9911 if(!chartEl)return;
9912 leg.addEventListener('mouseenter',function(){{
9913 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
9914 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
9915 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
9916 x.style.opacity='1';
9917 }} else {{
9918 x.style.opacity='0.28';
9919 }}
9920 }});
9921 }});
9922 leg.addEventListener('mouseleave',function(){{
9923 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
9924 }});
9925 }});
9926 // Author handles
9927 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
9928
9929 // ── Export helpers ────────────────────────────────────────────────────────
9930 // Fetch one image from the server and return a data-URI Promise
9931 function mcFetchUri(path){{
9932 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
9933 return new Promise(function(res){{
9934 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
9935 }});
9936 }}).catch(function(){{return '';}});
9937 }}
9938 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
9939 function mcInlineImgs(html,cb){{
9940 var paths=[],seen={{}};
9941 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
9942 if(!paths.length){{cb(html);return;}}
9943 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
9944 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
9945 .catch(function(){{cb(html);}});
9946 }}
9947 // Capture full-page HTML with all table rows visible
9948 function mcRawHtml(pdfMode){{
9949 if(pdfMode)document.body.classList.add('pdf-mode');
9950 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
9951 var html=document.documentElement.outerHTML;
9952 perPage=s;currentPage=p;renderFilePage();
9953 if(pdfMode)document.body.classList.remove('pdf-mode');
9954 return html;
9955 }}
9956
9957 // HTML export (full page with inlined images)
9958 function mcDoHtml(btn,fname){{
9959 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
9960 mcInlineImgs(mcRawHtml(false),function(html){{
9961 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
9962 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
9963 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
9964 btn.disabled=false;btn.innerHTML=orig;
9965 }});
9966 }}
9967 // PDF export — comprehensive document-style report: full numbers, all sections
9968 function mcBuildPdfHtml(){{
9969 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
9970 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
9971 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
9972 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>';}}
9973 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
9974 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
9975 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)));}}
9976 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
9977 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
9978 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
9979 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
9980 '.hdr{{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
9981 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
9982 '.title{{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}}'+
9983 '.proj{{font-size:12px;color:#99aabb;margin-top:3px;}}'+
9984 '.hr{{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}}'+
9985 '.body{{padding:18px 24px;}}'+
9986 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px;}}'+
9987 '.sc{{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}}'+
9988 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
9989 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
9990 '.sec{{margin-bottom:20px;}}'+
9991 '.sh{{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}}'+
9992 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
9993 'th{{background:#1a2035;color:#fff;padding:5px 8px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;}}'+
9994 'td{{border-bottom:1px solid #eee;padding:4px 8px;vertical-align:middle;}}'+
9995 'tr:nth-child(even) td{{background:#faf8f6;}}'+
9996 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:20px;}}';
9997 // ── Metric Progression ────────────────────────────────────────────────
9998 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
9999 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
10000 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>';
10001 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
10002 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
10003 var progRows=POINTS.map(function(pt,i){{
10004 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)));
10005 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
10006 '<td style="text-align:right">'+full(pt.code)+'</td>'+
10007 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
10008 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
10009 '<td style="text-align:right">'+full(pt.files)+'</td>';
10010 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
10011 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
10012 return r+'</tr>';
10013 }}).join('');
10014 // ── Scan-to-scan changes ──────────────────────────────────────────────
10015 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
10016 var prev=POINTS[i];
10017 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
10018 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
10019 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
10020 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
10021 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
10022 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
10023 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
10024 }}).join(''):'';
10025 // ── File matrix (top 50 by |total delta|) ────────────────────────────
10026 var fmSection='';
10027 if(FILES&&FILES.length){{
10028 // Hard cap on per-scan columns so the table never overflows the page width.
10029 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
10030 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
10031 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
10032 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
10033 fmHdr+='<th style="text-align:right">Total \u0394</th>';
10034 var fmRows=topFiles.map(function(f){{
10035 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
10036 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>';
10037 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
10038 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
10039 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>';
10040 }}).join('');
10041 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
10042 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
10043 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
10044 }}
10045 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
10046 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
10047 '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Multi-Scan Timeline</div><div class="proj">{project_label}</div></div>'+
10048 '<div class="hr">{n} scans<br><span style="color:#7a8b9c">'+commitsList+'</span><br>Generated: '+esc(now)+'</div></div>'+
10049 '<div class="body">'+
10050 '<div class="sg">'+
10051 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
10052 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
10053 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
10054 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
10055 (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>':
10056 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
10057 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
10058 '</div>'+
10059 '<div class="sec"><p class="sh">Metric Progression</p>'+
10060 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
10061 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
10062 '<table><thead><tr><th style="text-align:center">Scans</th>'+
10063 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
10064 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
10065 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
10066 fmSection+
10067 '</div>'+
10068 '<div class="ftr"><span>oxide-sloc v{version}</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div>'+
10069 '</body></html>';
10070 }}
10071 function mcDoPdf(btn){{
10072 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
10073 var html=mcBuildPdfHtml();
10074 fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:html,filename:mcExportName('pdf')}})}})
10075 .then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
10076 .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);}})
10077 .catch(function(e){{alert('PDF export failed: '+e.message);}})
10078 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
10079 }}
10080
10081 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
10082 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
10083 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
10084 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
10085 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
10086 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
10087 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
10088 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
10089 if(location.protocol==='file:'){{
10090 [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';}}}} );
10091 [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';}}}} );
10092 }}
10093 }})();
10094 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
10095 (function(){{
10096 function $(id){{return document.getElementById(id);}}
10097 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10098 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
10099 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
10100 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
10101 function openModal(idx){{
10102 var ov=$('mc-modal-overlay');if(!ov)return;
10103 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
10104 if(idx<0||idx>=N)return;
10105 var pt=POINTS[idx];
10106 titleEl.textContent='Scan '+(idx+1);
10107 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
10108 subEl.textContent=lbl;
10109 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
10110 '<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>'+
10111 '<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>'+
10112 '<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>'+
10113 '<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>'+
10114 (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>':'')+
10115 (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>':'')+
10116 '</div></div>';
10117 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
10118 (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>':'')+
10119 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
10120 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
10121 (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>':'')+
10122 (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>':'')+
10123 (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>':'')+
10124 (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>':'')+
10125 '<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>'+
10126 '</div>';
10127 var dHtml='';
10128 if(idx>0){{
10129 var prev=POINTS[idx-1];
10130 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
10131 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
10132 '<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>'+
10133 '<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>'+
10134 '<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>'+
10135 '</div></div>';
10136 }}
10137 bodyEl.innerHTML=sHtml+iHtml+dHtml;
10138 ov.classList.add('open');document.body.style.overflow='hidden';
10139 }}
10140 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
10141 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
10142 document.addEventListener('click',function(e){{
10143 if(!e.target||!e.target.closest)return;
10144 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
10145 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
10146 var card=e.target.closest('.mc-card');
10147 if(!card)return;
10148 if(e.target.closest('a'))return;
10149 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
10150 var i=cards.indexOf(card);
10151 if(i>=0)openModal(i);
10152 }});
10153 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
10154 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
10155 var statTip=null;
10156 document.addEventListener('mousemove',function(e){{
10157 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
10158 if(!box){{if(statTip)statTip.style.display='none';return;}}
10159 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
10160 var tip=box.getAttribute('data-tip')||'';
10161 if(statTip.textContent!==tip)statTip.textContent=tip;
10162 statTip.style.display='block';
10163 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
10164 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
10165 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
10166 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
10167 }});
10168 (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');}})();
10169 }})();
10170 }})();
10171 </script>
10172 <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]';
10173 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;}}
10174 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>
10175 <!-- Scan card detail modal -->
10176 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
10177 <div class="mc-modal" id="mc-modal">
10178 <div class="mc-modal-head">
10179 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
10180 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
10181 </div>
10182 <div class="mc-modal-body" id="mc-modal-body"></div>
10183 </div>
10184 </div>
10185</body>
10186</html>"##,
10187 project_label = html_escape(project_label),
10188 n = n,
10189 scan_strip = scan_strip,
10190 mc_strip_class = mc_strip_class,
10191 metrics_thead = metrics_thead,
10192 metrics_tbody = metrics_tbody,
10193 file_col_headers = file_col_headers,
10194 total_files = total_files,
10195 files_modified = files_modified,
10196 files_added = files_added,
10197 files_removed = files_removed,
10198 files_unchanged = files_unchanged,
10199 points_json = points_json,
10200 file_matrix_json = file_matrix_json,
10201 nav_compare_active = nav_compare_active,
10202 version = version,
10203 csp_nonce = csp_nonce,
10204 scope_bar_html = scope_bar_html,
10205 scope_label = scope_label,
10206 )
10207}
10208
10209#[allow(clippy::too_many_lines)] async fn trend_report_handler(
10217 State(state): State<AppState>,
10218 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10219) -> Response {
10220 auto_scan_watched_dirs(&state).await;
10221
10222 let watched_dirs_list: Vec<String> = {
10223 let wd = state.watched_dirs.lock().await;
10224 wd.dirs.iter().map(|p| p.display().to_string()).collect()
10225 };
10226
10227 let roots: Vec<String> = {
10229 let reg = state.registry.lock().await;
10230 let mut seen = std::collections::BTreeSet::new();
10231 reg.entries
10232 .iter()
10233 .flat_map(|e| e.input_roots.iter().cloned())
10234 .filter(|r| seen.insert(r.clone()))
10235 .collect()
10236 };
10237
10238 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
10239 let nonce = &csp_nonce;
10240 let version = env!("CARGO_PKG_VERSION");
10241
10242 let watched_dirs_html: String = if state.server_mode {
10246 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()
10247 } else {
10248 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
10249 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
10250 .to_string()
10251 } else {
10252 watched_dirs_list
10253 .iter()
10254 .fold(String::new(), |mut s, d| {
10255 use std::fmt::Write as _;
10256 let escaped =
10257 d.replace('&', "&").replace('"', """).replace('<', "<");
10258 write!(
10259 s,
10260 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>"#
10261 ).expect("write to String is infallible");
10262 s
10263 })
10264 };
10265 format!(
10266 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>"#
10267 )
10268 };
10269
10270 let html = format!(
10271 r##"<!doctype html>
10272<html lang="en">
10273<head>
10274 <meta charset="utf-8" />
10275 <meta name="viewport" content="width=device-width, initial-scale=1" />
10276 <title>OxideSLOC | Trend Reports</title>
10277 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10278 <style nonce="{nonce}">
10279 :root {{
10280 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
10281 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
10282 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
10283 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
10284 --info-bg:#eef3ff; --info-text:#4467d8;
10285 }}
10286 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
10287 *{{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;}}
10288 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10289 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
10290 .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;}}
10291 @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));}}}}
10292 .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);}}
10293 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
10294 .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));}}
10295 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10296 .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;}}
10297 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
10298 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
10299 @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; }} }}
10300 .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;}}
10301 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10302 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10303 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10304 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
10305 .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;}}
10306 .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;}}
10307 .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;}}
10308 .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;}}
10309 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10310 .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);}}
10311 .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;}}
10312 .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;}}
10313 .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;}}
10314 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10315 .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;}}
10316 .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);}}
10317 .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;}}
10318 .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;}}
10319 .tz-select:focus{{border-color:var(--oxide);}}
10320 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10321 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
10322 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
10323 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
10324 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
10325 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
10326 .trend-title-block{{flex:1;min-width:0;}}
10327 .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;}}
10328 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
10329 .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;}}
10330 .chart-select:focus{{border-color:var(--accent);}}
10331 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
10332 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
10333 .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;}}
10334 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
10335 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
10336 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
10337 .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);}}
10338 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
10339 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
10340 .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;}}
10341 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
10342 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
10343 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
10344 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
10345 .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;}}
10346 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
10347 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
10348 .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);}}
10349 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
10350 .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;}}
10351 .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;}}
10352 .data-table tr:last-child td{{border-bottom:none;}}
10353 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
10354 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
10355 .table-wrap{{width:100%;overflow-x:auto;}}
10356 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
10357 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
10358 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10359 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
10360 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
10361 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
10362 .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;}}
10363 .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;}}
10364 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10365 .pagination-info{{font-size:13px;color:var(--muted);}}
10366 .pagination-btns{{display:flex;gap:6px;}}
10367 .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;}}
10368 .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;}}
10369 #scan-history-table col:nth-child(1){{width:155px;}}
10370 #scan-history-table col:nth-child(2){{width:240px;}}
10371 #scan-history-table col:nth-child(3){{width:82px;}}
10372 #scan-history-table col:nth-child(4){{width:82px;}}
10373 #scan-history-table col:nth-child(5){{width:90px;}}
10374 #scan-history-table col:nth-child(6){{width:90px;}}
10375 #scan-history-table col:nth-child(7){{width:88px;}}
10376 #scan-history-table col:nth-child(8){{width:150px;}}
10377 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
10378 .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;}}
10379 .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;}}
10380 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
10381 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
10382 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
10383 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
10384 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
10385 .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;}}
10386 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
10387 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
10388 .watched-chip-rm:hover{{color:var(--oxide);}}
10389 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
10390 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
10391 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
10392 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
10393 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
10394 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
10395 a.run-link:hover{{text-decoration:underline;}}
10396 .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);}}
10397 .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);}}
10398 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
10399 .metric-num{{font-weight:700;color:var(--text);}}
10400 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
10401 .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;}}
10402 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10403 .btn.primary:hover{{opacity:.9;}}
10404 .rpt-btn{{min-width:58px;justify-content:center;}}
10405 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
10406 .report-cell{{overflow:visible!important;white-space:normal!important;}}
10407 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
10408 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
10409 .submod-details summary::-webkit-details-marker{{display:none;}}
10410 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
10411 .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;}}
10412 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
10413 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
10414 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
10415 .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;}}
10416 .export-btn:hover{{background:var(--line);}}
10417 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
10418 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10419 .site-footer a{{color:var(--muted);}}
10420 .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;}}
10421 .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;}}
10422 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
10423 </style>
10424</head>
10425<body>
10426 <div class="background-watermarks" aria-hidden="true">
10427 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10428 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10429 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10430 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10431 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10432 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
10433 </div>
10434 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10435 <div class="top-nav">
10436 <div class="top-nav-inner">
10437 <a class="brand" href="/">
10438 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10439 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
10440 </a>
10441 <div class="nav-right">
10442 <a class="nav-pill" href="/">Home</a>
10443 <div class="nav-dropdown">
10444 <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>
10445 <div class="nav-dropdown-menu">
10446 <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>
10447 </div>
10448 </div>
10449 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
10450 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10451 <div class="nav-dropdown">
10452 <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>
10453 <div class="nav-dropdown-menu">
10454 <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>
10455 </div>
10456 </div>
10457 <div class="server-status-wrap" id="server-status-wrap">
10458 <div class="nav-pill server-online-pill" id="server-status-pill">
10459 <span class="status-dot" id="status-dot"></span>
10460 <span id="server-status-label">Server</span>
10461 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10462 </div>
10463 <div class="server-status-tip">
10464 OxideSLOC is running — accessible on your network.
10465 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10466 </div>
10467 </div>
10468 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10469 <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>
10470 </button>
10471 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10472 <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>
10473 <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>
10474 </button>
10475 </div>
10476 </div>
10477 </div>
10478
10479 <div class="page">
10480 {watched_dirs_html}
10481 <div class="summary-strip" id="trend-stats"></div>
10482 <div class="panel">
10483 <div class="trend-header">
10484 <div class="trend-title-block">
10485 <h1>Trend Reports</h1>
10486 <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>
10487 <span class="chart-hint-inline">
10488 <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>
10489 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
10490 </span>
10491 </div>
10492 <div class="chart-actions">
10493 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
10494 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
10495 Retention Policy
10496 </button>
10497 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
10498 <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>
10499 Clean up old runs
10500 </button>
10501 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
10502 <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>
10503 Export Excel
10504 </button>
10505 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
10506 <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>
10507 Export PNG
10508 </button>
10509 </div>
10510 </div>
10511
10512 <div class="controls-centered">
10513 <label>Project Root:
10514 <select class="chart-select" id="root-sel">
10515 <option value="">All projects</option>
10516 </select>
10517 </label>
10518 <label>Y Metric:
10519 <select class="chart-select" id="y-sel">
10520 <option value="code_lines">Code Lines</option>
10521 <option value="comment_lines">Comment Lines</option>
10522 <option value="blank_lines">Blank Lines</option>
10523 <option value="physical_lines">Physical Lines</option>
10524 <option value="files_analyzed">Files Analyzed</option>
10525 </select>
10526 </label>
10527 <label>X Axis:
10528 <select class="chart-select" id="x-sel">
10529 <option value="time">By Time</option>
10530 <option value="commit">By Commit</option>
10531 <option value="release">By Release</option>
10532 <option value="tag">Tagged Commits</option>
10533 </select>
10534 </label>
10535 <label id="submodule-label" style="display:none;">Submodule:
10536 <select class="chart-select" id="sub-sel">
10537 <option value="">All (project total)</option>
10538 </select>
10539 </label>
10540 <label>Chart Size:
10541 <select class="chart-select" id="scale-sel">
10542 <option value="0.75">Compact</option>
10543 <option value="1.2" selected>Normal</option>
10544 <option value="1.38">Large</option>
10545 </select>
10546 </label>
10547 </div>
10548
10549 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
10550 <div id="data-table-wrap" style="overflow-x:auto;"></div>
10551 </div>
10552 </div>
10553
10554 <script nonce="{nonce}">
10555 (function() {{
10556 // Theme persistence
10557 var b = document.body;
10558 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
10559 var tgl = document.getElementById('theme-toggle');
10560 if (tgl) tgl.addEventListener('click', function() {{
10561 var d = b.classList.toggle('dark-theme');
10562 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
10563 }});
10564
10565 // Watermark randomizer
10566 (function() {{
10567 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10568 if (!wms.length) return;
10569 var placed = [];
10570 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;}}
10571 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];}}
10572 var half=Math.floor(wms.length/2);
10573 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;}});
10574 }})();
10575
10576 // Code particles
10577 (function() {{
10578 var container = document.getElementById('code-particles');
10579 if (!container) return;
10580 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'];
10581 for (var i = 0; i < 38; i++) {{
10582 (function(idx) {{
10583 var el = document.createElement('span');
10584 el.className = 'code-particle';
10585 el.textContent = snippets[idx % snippets.length];
10586 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
10587 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
10588 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
10589 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';
10590 container.appendChild(el);
10591 }})(i);
10592 }}
10593 }})();
10594
10595 // Watched folder picker
10596 (function() {{
10597 var btn = document.getElementById('add-watched-btn');
10598 if (!btn) return;
10599 btn.addEventListener('click', function() {{
10600 fetch('/pick-directory?kind=reports')
10601 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
10602 .then(function(data) {{
10603 if (!data.cancelled && data.selected_path) {{
10604 var form = document.createElement('form');
10605 form.method = 'POST';
10606 form.action = '/watched-dirs/add';
10607 var ri = document.createElement('input');
10608 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
10609 var fi = document.createElement('input');
10610 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
10611 form.appendChild(ri); form.appendChild(fi);
10612 document.body.appendChild(form);
10613 form.submit();
10614 }}
10615 }})
10616 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
10617 }});
10618 }})();
10619
10620 // Settings / color-scheme modal
10621 (function() {{
10622 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'}}];
10623 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);}});}}
10624 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
10625 var btn=document.getElementById('settings-btn');if(!btn)return;
10626 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10627 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>';
10628 document.body.appendChild(m);
10629 var g=document.getElementById('scheme-grid');
10630 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);}});
10631 var cl=document.getElementById('settings-close');
10632 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);
10633 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');}});
10634 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10635 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10636 }})();
10637 }})();
10638
10639 var ROOTS = {roots_json};
10640 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
10641 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
10642 var allData = [];
10643
10644 // Populate root selector
10645 var rootSel = document.getElementById('root-sel');
10646 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
10647
10648 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();}}
10649 function fmtFull(n){{return Number(n).toLocaleString();}}
10650 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
10651
10652 // Tooltip
10653 var tt = document.createElement('div');
10654 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);';
10655 document.body.appendChild(tt);
10656 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
10657 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';}}
10658 function hideTT(){{tt.style.display='none';}}
10659 window.addEventListener('blur',function(){{hideTT();}});
10660 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
10661
10662 function statExact(compact, full){{
10663 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
10664 }}
10665 function statVal(n){{
10666 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
10667 }}
10668
10669 function updateStats(data){{
10670 var statsEl=document.getElementById('trend-stats');
10671 if(!statsEl)return;
10672 if(!data||!data.length){{statsEl.innerHTML='';return;}}
10673 var yKey=document.getElementById('y-sel').value;
10674 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10675 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10676 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
10677 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
10678 var absDelta=Math.abs(delta);
10679 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
10680 var deltaExact=statExact(deltaCompact,deltaFull);
10681 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
10682 statsEl.innerHTML=
10683 '<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>'+
10684 '<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>'+
10685 '<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>'+
10686 '<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>';
10687 }}
10688
10689 var subSel = document.getElementById('sub-sel');
10690 var subLabel = document.getElementById('submodule-label');
10691
10692 function populateSubmodules(root){{
10693 if(!subSel||!subLabel)return;
10694 while(subSel.options.length>1)subSel.remove(1);
10695 subSel.value='';
10696 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
10697 fetch(url)
10698 .then(function(r){{return r.json();}})
10699 .then(function(subs){{
10700 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
10701 subs.forEach(function(s){{
10702 var o=document.createElement('option');
10703 o.value=s.name;
10704 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
10705 subSel.appendChild(o);
10706 }});
10707 subLabel.style.display='';
10708 }})
10709 .catch(function(){{subLabel.style.display='none';}});
10710 }}
10711
10712 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
10713
10714 function loadAndRender(){{
10715 var root = rootSel.value;
10716 var sub = subSel ? subSel.value : '';
10717 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
10718 document.getElementById('data-table-wrap').innerHTML='';
10719 var url = '/api/metrics/history?limit=100'
10720 + (root ? '&root='+encodeURIComponent(root) : '')
10721 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
10722 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
10723 allData = data;
10724 render(data);
10725 updateStats(data);
10726 }}).catch(function(){{
10727 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>';
10728 }});
10729 }}
10730
10731 function render(data){{
10732 var yKey = document.getElementById('y-sel').value;
10733 var xMode = document.getElementById('x-sel').value;
10734
10735 // Filter for tag/release mode
10736 var pts = data;
10737 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
10738
10739 // Sort oldest-first for the line chart
10740 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
10741
10742 var wrap = document.getElementById('chart-wrap');
10743 if(!pts.length){{
10744 var emptyMsg = (xMode === 'tag')
10745 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
10746 : 'No scan data found for the selected filters.';
10747 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
10748 renderTable([]);
10749 return;
10750 }}
10751
10752 var scaleEl=document.getElementById('scale-sel');
10753 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
10754 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;
10755 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
10756
10757 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
10758
10759 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">';
10760 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>';
10761
10762 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
10763
10764 // Grid + Y axis ticks
10765 for(var ti=0;ti<=5;ti++){{
10766 var gy=PT+CH-Math.round(ti/5*CH);
10767 var gv=Math.round(ti/5*maxY);
10768 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
10769 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
10770 }}
10771
10772 // X axis labels (every N-th point to avoid crowding)
10773 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
10774 pts.forEach(function(d,i){{
10775 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10776 if(i%labelEvery===0||i===pts.length-1){{
10777 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)));
10778 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>';
10779 }}
10780 }});
10781
10782 // Axis label
10783 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
10784 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>';
10785 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>';
10786
10787 // Area fill + line path
10788 var pathD='';
10789 pts.forEach(function(d,i){{
10790 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10791 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10792 pathD+=(i===0?'M':'L')+x+','+y;
10793 }});
10794 if(pts.length>1){{
10795 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
10796 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
10797 }}
10798 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
10799
10800 // Data points (clickable) + permanent value labels
10801 var showLabels = pts.length <= 40;
10802 var labelEveryN = pts.length > 20 ? 2 : 1;
10803 pts.forEach(function(d,i){{
10804 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
10805 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
10806 var hasTags=d.tags&&d.tags.length>0;
10807 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
10808 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
10809 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+'"/>';
10810 if(showLabels && i%labelEveryN===0){{
10811 var lx=x, ly=y-r-5;
10812 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>';
10813 }}
10814 }});
10815
10816 svg+='</svg>';
10817 wrap.innerHTML=svg;
10818
10819 // Attach point tooltips
10820 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
10821 c.addEventListener('mouseover',function(e){{
10822 var d=pts[parseInt(this.dataset.idx)];
10823 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(''):'';
10824 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>':'';
10825 showTT(e,
10826 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
10827 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
10828 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
10829 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
10830 );
10831 this.setAttribute('r','8');
10832 }});
10833 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
10834 c.addEventListener('mousemove',moveTT);
10835 c.addEventListener('click',function(){{
10836 var d=pts[parseInt(this.dataset.idx)];
10837 if(d.html_url) window.open(d.html_url,'_blank');
10838 }});
10839 }});
10840
10841 renderTable(pts, yKey);
10842 }}
10843
10844 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
10845 var shProjFilter='', shBranchFilter='';
10846
10847 function fmtPST(isoStr){{
10848 if(!isoStr)return'';
10849 var d=new Date(isoStr);
10850 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
10851 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);}}
10852 function p(n){{return n<10?'0'+n:String(n);}}
10853 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++;}}}}
10854 var yr=d.getUTCFullYear();
10855 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
10856 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
10857 var isDST=d>=dstStart&&d<dstEnd;
10858 var off=isDST?-7*3600*1000:-8*3600*1000;
10859 var lbl=isDST?'PDT':'PST';
10860 var loc=new Date(d.getTime()+off);
10861 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
10862 }}
10863
10864 function getShRows(){{
10865 var proj=shProjFilter.toLowerCase().trim();
10866 var branch=shBranchFilter;
10867 return shData.filter(function(d){{
10868 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
10869 if(branch&&(d.branch||'')!==branch)return false;
10870 return true;
10871 }});
10872 }}
10873
10874 function renderShPage(){{
10875 var filtered=getShRows();
10876 if(shSortCol){{
10877 filtered.sort(function(a,b){{
10878 var va,vb;
10879 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
10880 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
10881 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
10882 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
10883 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
10884 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
10885 }});
10886 }}
10887 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
10888 shPage=Math.min(shPage,totalPages);
10889 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
10890 var visible=filtered.slice(start,end);
10891 var tbody=document.getElementById('sh-tbody');
10892 if(!tbody)return;
10893 tbody.innerHTML=visible.map(function(d){{
10894 var tsHtml=esc(fmtPST(d.timestamp));
10895 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>';
10896 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>';
10897 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
10898 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
10899 var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
10900 var reportCell='';
10901 if(d.html_url){{
10902 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
10903 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>';}}
10904 reportCell+='</div>';
10905 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
10906 if(d.submodule_links&&d.submodule_links.length){{
10907 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
10908 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
10909 reportCell+='</div></details>';
10910 }}
10911 return '<tr>'
10912 +'<td>'+tsHtml+'</td>'
10913 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
10914 +'<td>'+runIdHtml+'</td>'
10915 +'<td>'+commitHtml+'</td>'
10916 +'<td>'+branchHtml+'</td>'
10917 +'<td>'+tags+'</td>'
10918 +'<td class="num">'+metricHtml+'</td>'
10919 +'<td class="report-cell">'+reportCell+'</td>'
10920 +'</tr>';
10921 }}).join('');
10922 var pgRange=document.getElementById('sh-pg-range');
10923 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
10924 var pgInfo=document.getElementById('sh-pg-info');
10925 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
10926 var pgBtns=document.getElementById('sh-pg-btns');
10927 if(pgBtns){{
10928 pgBtns.innerHTML='';
10929 function mkPgBtn(lbl,pg,active,disabled){{
10930 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
10931 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
10932 return b;
10933 }}
10934 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
10935 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
10936 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
10937 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
10938 }}
10939 }}
10940
10941 function wireTableBehavior(){{
10942 var pf=document.getElementById('sh-proj-filter');
10943 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
10944 var bf=document.getElementById('sh-branch-filter');
10945 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
10946 var rb=document.getElementById('sh-reset-btn');
10947 if(rb)rb.addEventListener('click',function(){{
10948 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
10949 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
10950 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
10951 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');}});
10952 renderShPage();
10953 }});
10954 var pps=document.getElementById('sh-per-page');
10955 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
10956 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
10957 ths.forEach(function(th){{
10958 th.addEventListener('click',function(e){{
10959 if(e.target.classList.contains('col-resize-handle'))return;
10960 var col=th.dataset.col;
10961 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
10962 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
10963 th.classList.add('sort-'+shSortOrder);
10964 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
10965 shPage=1;renderShPage();
10966 }});
10967 }});
10968 var table=document.getElementById('scan-history-table');
10969 if(!table)return;
10970 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
10971 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
10972 allThs.forEach(function(th,i){{
10973 var handle=th.querySelector('.col-resize-handle');
10974 if(!handle||!cols[i])return;
10975 var startX,startW;
10976 handle.addEventListener('mousedown',function(e){{
10977 e.stopPropagation();e.preventDefault();
10978 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
10979 handle.classList.add('dragging');
10980 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
10981 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
10982 document.addEventListener('mousemove',onMove);
10983 document.addEventListener('mouseup',onUp);
10984 }});
10985 }});
10986 }}
10987
10988 function renderTable(pts, yKey){{
10989 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
10990 var wrap=document.getElementById('data-table-wrap');
10991 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
10992 var yLabel=Y_LABELS[yKey]||yKey||'';
10993 shData=pts.slice().reverse();
10994 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
10995 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
10996 var branches={{}};
10997 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
10998 var branchOpts='<option value="">All branches</option>';
10999 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
11000 wrap.innerHTML=
11001 '<div class="chart-section-header">SCAN HISTORY</div>'+
11002 '<div class="filter-row">'+
11003 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
11004 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
11005 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
11006 '</div>'+
11007 '<div class="table-wrap">'+
11008 '<table id="scan-history-table" class="data-table">'+
11009 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
11010 '<thead><tr id="sh-thead">'+
11011 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
11012 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
11013 '<th>Run ID<div class="col-resize-handle"></div></th>'+
11014 '<th>Commit<div class="col-resize-handle"></div></th>'+
11015 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
11016 '<th>Tags<div class="col-resize-handle"></div></th>'+
11017 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
11018 '<th>Report<div class="col-resize-handle"></div></th>'+
11019 '</tr></thead>'+
11020 '<tbody id="sh-tbody"></tbody>'+
11021 '</table>'+
11022 '</div>'+
11023 '<div class="pagination">'+
11024 '<span class="pagination-info" id="sh-pg-info"></span>'+
11025 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
11026 '<div style="display:flex;align-items:center;gap:8px;">'+
11027 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
11028 '<select class="filter-select" id="sh-per-page">'+
11029 '<option value="10">10 per page</option>'+
11030 '<option value="25" selected>25 per page</option>'+
11031 '<option value="50">50 per page</option>'+
11032 '<option value="100">100 per page</option>'+
11033 '</select>'+
11034 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
11035 '</div>'+
11036 '</div>';
11037 wireTableBehavior();
11038 renderShPage();
11039 }}
11040
11041 function exportXLSX(){{
11042 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
11043 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
11044 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
11045 var s1R=sorted.map(function(d){{
11046 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||''];
11047 }});
11048 var pm={{}};
11049 sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
11050 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'];
11051 var s2R=Object.keys(pm).map(function(p){{
11052 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11053 var lat=sc[sc.length-1],fst=sc[0];
11054 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
11055 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);
11056 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];
11057 }});
11058 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
11059 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
11060 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
11061 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
11062 }}
11063
11064 function buildXLSX(sheets,chartRows,chartRows2){{
11065 function s2b(s){{return new TextEncoder().encode(s);}}
11066 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
11067 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;}}
11068 function crc32(d){{
11069 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;}}}}
11070 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
11071 }}
11072 function buildSheet(hdr,rows,drawRid,withCtrl){{
11073 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
11074 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
11075 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
11076 x+='<row r="1">';
11077 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
11078 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>';}}
11079 x+='</row>';
11080 rows.forEach(function(row,ri){{
11081 var rn=ri+2;
11082 x+='<row r="'+rn+'">';
11083 row.forEach(function(cell,ci){{
11084 var addr=col2l(ci+1)+rn;
11085 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
11086 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
11087 }});
11088 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>';}}
11089 x+='</row>';
11090 }});
11091 x+='</sheetData>';
11092 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>';}}
11093 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
11094 return x+'</worksheet>';
11095 }}
11096 function buildChartXML(rows){{
11097 var sn="'Scan History'";
11098 var nr=rows.length,er=nr+1;
11099 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'}}];
11100 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11101 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">';
11102 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11103 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11104 sd.forEach(function(s,i){{
11105 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11106 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>';
11107 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11108 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>';
11109 var dlp=(i===2)?'b':'t';
11110 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>';
11111 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11112 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11113 x+='</c:strCache></c:strRef></c:cat>';
11114 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+'"/>';
11115 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11116 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11117 }});
11118 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
11119 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>';
11120 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>';
11121 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11122 return x;
11123 }}
11124 function buildChartXML2(rows){{
11125 var sn="'By Project'";
11126 var nr=rows.length,er=nr+1;
11127 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'}}];
11128 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11129 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">';
11130 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
11131 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11132 sd.forEach(function(s,i){{
11133 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
11134 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>';
11135 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
11136 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>';
11137 var dlp=(i===2)?'b':'t';
11138 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>';
11139 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11140 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11141 x+='</c:strCache></c:strRef></c:cat>';
11142 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+'"/>';
11143 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
11144 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11145 }});
11146 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
11147 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>';
11148 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>';
11149 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
11150 return x;
11151 }}
11152 function buildChartXML3(rows){{
11153 var sn="'Scan History'";
11154 var nr=rows.length,er=nr+1;
11155 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11156 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">';
11157 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
11158 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
11159 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
11160 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>';
11161 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
11162 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>';
11163 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>';
11164 x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
11165 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
11166 x+='</c:strCache></c:strRef></c:cat>';
11167 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+'"/>';
11168 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
11169 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
11170 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
11171 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>';
11172 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>';
11173 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>';
11174 return x;
11175 }}
11176 var hasChart=!!(chartRows&&chartRows.length);
11177 var nr=hasChart?chartRows.length:0;
11178 var hasChart2=!!(chartRows2&&chartRows2.length);
11179 var nr2=hasChart2?chartRows2.length:0;
11180 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>';
11181 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"/>';
11182 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
11183 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"/>';}}
11184 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"/>';}}
11185 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
11186 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>';
11187 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
11188 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"/>';}});
11189 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
11190 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>';
11191 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
11192 wbx+='</sheets></workbook>';
11193 var files=[
11194 {{name:'[Content_Types].xml',data:s2b(ct)}},
11195 {{name:'_rels/.rels',data:s2b(dotrels)}},
11196 {{name:'xl/workbook.xml',data:s2b(wbx)}},
11197 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
11198 {{name:'xl/styles.xml',data:s2b(styl)}}
11199 ];
11200 // Chart embedded directly in Scan History (sheet1); By Project is plain
11201 sheets.forEach(function(s,i){{
11202 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)))}});
11203 }});
11204 if(hasChart){{
11205 var fromRow=nr+4,toRow=nr+24;
11206 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>')}});
11207 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11208 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">';
11209 drx+='<xdr:twoCellAnchor editAs="twoCell">';
11210 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>';
11211 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>';
11212 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11213 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11214 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11215 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11216 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
11217 var focRow=toRow+2,focRowEnd=toRow+22;
11218 drx+='<xdr:twoCellAnchor editAs="twoCell">';
11219 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>';
11220 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>';
11221 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11222 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11223 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11224 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
11225 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
11226 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
11227 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>')}});
11228 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
11229 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
11230 }}
11231 if(hasChart2){{
11232 var fromRow2=nr2+4,toRow2=nr2+24;
11233 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>')}});
11234 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
11235 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">';
11236 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
11237 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>';
11238 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>';
11239 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
11240 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
11241 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
11242 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
11243 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
11244 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
11245 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>')}});
11246 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
11247 }}
11248 var parts=[],offsets=[],total=0;
11249 files.forEach(function(f){{
11250 offsets.push(total);
11251 var nb=s2b(f.name),crc=crc32(f.data);
11252 var h=new DataView(new ArrayBuffer(30+nb.length));
11253 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
11254 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
11255 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
11256 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
11257 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
11258 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
11259 total+=30+nb.length+f.data.length;
11260 }});
11261 var cdStart=total;
11262 files.forEach(function(f,fi){{
11263 var nb=s2b(f.name),crc=crc32(f.data);
11264 var cd=new DataView(new ArrayBuffer(46+nb.length));
11265 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
11266 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
11267 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
11268 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
11269 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
11270 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
11271 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
11272 }});
11273 var cdSz=total-cdStart;
11274 var eocd=new DataView(new ArrayBuffer(22));
11275 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
11276 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
11277 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
11278 parts.push(new Uint8Array(eocd.buffer));
11279 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
11280 var out=new Uint8Array(sz);var off=0;
11281 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
11282 return out.buffer;
11283 }}
11284
11285 function exportPNG(){{
11286 var svgEl=document.querySelector('#chart-wrap svg');
11287 if(!svgEl){{alert('No chart to export yet.');return;}}
11288 var svgStr=new XMLSerializer().serializeToString(svgEl);
11289 var vb=svgEl.viewBox.baseVal,scale=2;
11290 var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
11291 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
11292 var url=URL.createObjectURL(blob);
11293 var img=new Image();
11294 img.onload=function(){{
11295 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
11296 var ctx=canvas.getContext('2d');
11297 var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
11298 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
11299 ctx.scale(scale,scale);ctx.drawImage(img,0,0);
11300 URL.revokeObjectURL(url);
11301 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
11302 }};
11303 img.src=url;
11304 }}
11305
11306 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
11307 var el=document.getElementById(id);
11308 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
11309 }});
11310 rootSel.addEventListener('change',function(){{
11311 populateSubmodules(rootSel.value);
11312 loadAndRender();
11313 }});
11314 if(subSel)subSel.addEventListener('change',loadAndRender);
11315
11316 var xlsxBtn=document.getElementById('export-xlsx-btn');
11317 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
11318 var pngBtn=document.getElementById('export-png-btn');
11319 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
11320
11321 // ── Clean-up modal ───────────────────────────────────────────────────────
11322 (function(){{
11323 var triggerBtn=document.getElementById('cleanup-runs-btn');
11324 if(!triggerBtn)return;
11325 var modal=document.createElement('div');
11326 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;';
11327 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);">'
11328 +'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
11329 +'<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>'
11330 +'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
11331 +'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
11332 +'<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;">'
11333 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
11334 +'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
11335 +'<div style="display:flex;gap:10px;justify-content:flex-end;">'
11336 +'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
11337 +'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
11338 +'</div></div>';
11339 document.body.appendChild(modal);
11340 triggerBtn.addEventListener('click',function(){{
11341 document.getElementById('cleanup-status').style.display='none';
11342 modal.style.display='flex';
11343 }});
11344 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
11345 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11346 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
11347 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
11348 var confirmBtn=this;
11349 confirmBtn.disabled=true;
11350 var status=document.getElementById('cleanup-status');
11351 status.style.display='block';
11352 status.style.background='#dbeafe';status.style.color='#1e40af';
11353 status.textContent='Deleting\u2026';
11354 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
11355 .then(function(resp){{
11356 return resp.json().then(function(d){{
11357 if(resp.ok){{
11358 status.style.background='#dcfce7';status.style.color='#166534';
11359 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
11360 setTimeout(function(){{window.location.reload();}},1500);
11361 }}else{{
11362 status.style.background='#fee2e2';status.style.color='#991b1b';
11363 status.textContent='Error: '+(d.error||'Unexpected error');
11364 confirmBtn.disabled=false;
11365 }}
11366 }});
11367 }})
11368 .catch(function(e){{
11369 status.style.background='#fee2e2';status.style.color='#991b1b';
11370 status.textContent='Network error: '+String(e);
11371 confirmBtn.disabled=false;
11372 }});
11373 }});
11374 }})();
11375
11376 // ── Retention policy panel ────────────────────────────────────────────────
11377 (function(){{
11378 var triggerBtn=document.getElementById('retention-policy-btn');
11379 if(!triggerBtn)return;
11380 var modal=document.createElement('div');
11381 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;';
11382 modal.innerHTML=''
11383 +'<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);">'
11384 +'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
11385 +'<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>'
11386 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
11387 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
11388 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
11389 +'</div>'
11390 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
11391 +'<div>'
11392 +'<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>'
11393 +'<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;">'
11394 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
11395 +'</div>'
11396 +'<div>'
11397 +'<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>'
11398 +'<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;">'
11399 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
11400 +'</div>'
11401 +'</div>'
11402 +'<div style="margin-bottom:20px;">'
11403 +'<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>'
11404 +'<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;">'
11405 +'<option value="1">Every hour</option>'
11406 +'<option value="6">Every 6 hours</option>'
11407 +'<option value="12">Every 12 hours</option>'
11408 +'<option value="24" selected>Every 24 hours</option>'
11409 +'<option value="48">Every 2 days</option>'
11410 +'<option value="72">Every 3 days</option>'
11411 +'<option value="168">Every week</option>'
11412 +'</select>'
11413 +'</div>'
11414 +'<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>'
11415 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
11416 +'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
11417 +'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
11418 +'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
11419 +'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
11420 +'</div>'
11421 +'</div>';
11422 document.body.appendChild(modal);
11423
11424 function rpShowStatus(msg,ok){{
11425 var s=document.getElementById('rp-status');
11426 s.style.display='block';
11427 s.style.background=ok?'#dcfce7':'#fee2e2';
11428 s.style.color=ok?'#166534':'#991b1b';
11429 s.textContent=msg;
11430 }}
11431 function fmtAgo(iso){{
11432 if(!iso)return'Never';
11433 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
11434 if(diff<60)return diff+'s ago';
11435 if(diff<3600)return Math.floor(diff/60)+'m ago';
11436 if(diff<86400)return Math.floor(diff/3600)+'h ago';
11437 return Math.floor(diff/86400)+'d ago';
11438 }}
11439 function loadPolicy(){{
11440 fetch('/api/cleanup-policy')
11441 .then(function(r){{return r.json();}})
11442 .then(function(d){{
11443 var p=d.policy;
11444 document.getElementById('rp-enabled').checked=p?p.enabled:false;
11445 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
11446 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
11447 var sel=document.getElementById('rp-interval');
11448 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;}}}}}}
11449 var lr=document.getElementById('rp-last-run');
11450 if(d.last_run_at){{
11451 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'):'');
11452 }}else{{
11453 lr.textContent='Auto-cleanup has not run yet.';
11454 }}
11455 }})
11456 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
11457 }}
11458
11459 triggerBtn.addEventListener('click',function(){{
11460 document.getElementById('rp-status').style.display='none';
11461 loadPolicy();
11462 modal.style.display='flex';
11463 }});
11464 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
11465 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
11466
11467 document.getElementById('rp-save-btn').addEventListener('click',function(){{
11468 var enabled=document.getElementById('rp-enabled').checked;
11469 var ageVal=document.getElementById('rp-max-age').value.trim();
11470 var countVal=document.getElementById('rp-max-count').value.trim();
11471 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
11472 if(enabled&&!ageVal&&!countVal){{
11473 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
11474 return;
11475 }}
11476 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
11477 var saveBtn=document.getElementById('rp-save-btn');
11478 saveBtn.disabled=true;
11479 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
11480 .then(function(r){{
11481 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
11482 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
11483 }})
11484 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11485 .finally(function(){{saveBtn.disabled=false;}});
11486 }});
11487
11488 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
11489 var btn=this;
11490 btn.disabled=true;
11491 btn.textContent='Running\u2026';
11492 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
11493 .then(function(r){{return r.json();}})
11494 .then(function(d){{
11495 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
11496 loadPolicy();
11497 }})
11498 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
11499 .finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
11500 }});
11501 }})();
11502
11503 populateSubmodules(rootSel.value);
11504 loadAndRender();
11505
11506 (function randomizeWatermarks() {{
11507 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11508 if (!wms.length) return;
11509 var placed = [];
11510 function tooClose(top, left) {{
11511 for (var i = 0; i < placed.length; i++) {{
11512 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
11513 if (dt < 16 && dl < 12) return true;
11514 }}
11515 return false;
11516 }}
11517 function pick(leftBand) {{
11518 for (var attempt = 0; attempt < 50; attempt++) {{
11519 var top = Math.random() * 88 + 2;
11520 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11521 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
11522 }}
11523 var top = Math.random() * 88 + 2;
11524 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
11525 placed.push([top, left]); return [top, left];
11526 }}
11527 var half = Math.floor(wms.length / 2);
11528 wms.forEach(function (img, i) {{
11529 var pos = pick(i < half);
11530 var size = Math.floor(Math.random() * 100 + 120);
11531 var rot = (Math.random() * 360).toFixed(1);
11532 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
11533 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;
11534 }});
11535 }})();
11536 (function spawnCodeParticles() {{
11537 var container = document.getElementById('code-particles');
11538 if (!container) return;
11539 var snippets = [
11540 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
11541 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
11542 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
11543 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
11544 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
11545 ];
11546 var count = 38;
11547 for (var i = 0; i < count; i++) {{
11548 (function(idx) {{
11549 var el = document.createElement('span');
11550 el.className = 'code-particle';
11551 el.textContent = snippets[idx % snippets.length];
11552 var left = Math.random() * 94 + 2;
11553 var top = Math.random() * 88 + 6;
11554 var dur = (Math.random() * 10 + 9).toFixed(1);
11555 var delay = (Math.random() * 18).toFixed(1);
11556 var rot = (Math.random() * 26 - 13).toFixed(1);
11557 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
11558 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
11559 container.appendChild(el);
11560 }})(i);
11561 }}
11562 }})();
11563 </script>
11564 <footer class="site-footer">
11565 local code analysis - metrics, history and reports
11566 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
11567 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
11568 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
11569 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
11570 · <a href="/api-docs" rel="noopener">REST API</a>
11571 </footer>
11572 <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>
11573</body>
11574</html>"##,
11575 );
11576
11577 Html(html).into_response()
11578}
11579
11580fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11581 use std::collections::HashMap;
11582 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
11583 return vec![];
11584 }
11585 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11586 for rec in per_file_records {
11587 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11588 let e = totals.entry(lang.display_name().to_string()).or_default();
11589 e.0 += u64::from(cov.lines_found);
11590 e.1 += u64::from(cov.lines_hit);
11591 }
11592 }
11593 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
11595 .into_iter()
11596 .filter(|(_, (found, _))| *found > 0)
11597 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11598 .collect();
11599 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11600 pairs
11601 .iter()
11602 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
11603 .collect()
11604}
11605
11606fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
11607 let mut high = 0u64;
11608 let mut mid = 0u64;
11609 let mut low = 0u64;
11610 for rec in per_file_records {
11611 if let Some(cov) = &rec.coverage {
11612 if cov.lines_found == 0 {
11613 continue;
11614 }
11615 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
11616 if pct >= 80.0 {
11617 high += 1;
11618 } else if pct >= 50.0 {
11619 mid += 1;
11620 } else {
11621 low += 1;
11622 }
11623 }
11624 }
11625 (high, mid, low)
11626}
11627
11628fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
11629 let mut arr: Vec<serde_json::Value> = per_file_records
11630 .iter()
11631 .filter_map(|rec| {
11632 rec.coverage.as_ref().map(|cov| {
11633 let line_pct = if cov.lines_found > 0 {
11634 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
11635 / 10.0
11636 } else {
11637 0.0
11638 };
11639 let fn_pct = if cov.functions_found > 0 {
11640 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
11641 .round()
11642 / 10.0
11643 } else {
11644 -1.0
11645 };
11646 serde_json::json!({
11647 "rel": rec.relative_path,
11648 "lang": rec.language.map_or("?", |l| l.display_name()),
11649 "line_pct": line_pct,
11650 "fn_pct": fn_pct,
11651 "lhit": cov.lines_hit,
11652 "lfound": cov.lines_found,
11653 "fhit": cov.functions_hit,
11654 "ffound": cov.functions_found,
11655 })
11656 })
11657 })
11658 .collect();
11659 arr.sort_by(|a, b| {
11660 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
11661 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
11662 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
11663 });
11664 arr
11665}
11666
11667#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
11669 let mut langs: Vec<&sloc_core::LanguageSummary> = run
11670 .totals_by_language
11671 .iter()
11672 .filter(|l| l.test_count > 0)
11673 .collect();
11674 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11675 let lang_tests: Vec<serde_json::Value> = langs
11676 .iter()
11677 .map(|l| {
11678 let d = if l.code_lines > 0 {
11679 l.test_count as f64 / l.code_lines as f64 * 1000.0
11680 } else {
11681 0.0
11682 };
11683 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11684 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11685 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11686 })
11687 .collect();
11688 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
11689 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11690 let t = &run.summary_totals;
11691 let total_tests = t.test_count;
11692 let density = if t.code_lines > 0 {
11693 total_tests as f64 / t.code_lines as f64 * 1000.0
11694 } else {
11695 0.0
11696 };
11697 let most_tested = langs.first().map_or_else(
11698 || "\u{2014}".to_string(),
11699 |l| l.language.display_name().to_string(),
11700 );
11701 let test_files: u64 = run
11702 .per_file_records
11703 .iter()
11704 .filter(|f| f.raw_line_categories.test_count > 0)
11705 .count() as u64;
11706 let cov_line = if t.coverage_lines_found > 0 {
11707 format!(
11708 "{:.1}",
11709 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
11710 )
11711 } else {
11712 "0".to_string()
11713 };
11714 let cov_fn = if t.coverage_functions_found > 0 {
11715 format!(
11716 "{:.1}",
11717 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
11718 )
11719 } else {
11720 "0".to_string()
11721 };
11722 let cov_branch = if t.coverage_branches_found > 0 {
11723 format!(
11724 "{:.1}",
11725 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
11726 )
11727 } else {
11728 "0".to_string()
11729 };
11730 let has_cov = !cov_arr.is_empty();
11731 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
11732 serde_json::json!({
11733 "totals": {
11734 "test_count": total_tests,
11735 "assertions": t.test_assertion_count,
11736 "suites": t.test_suite_count,
11737 "test_files": test_files,
11738 "total_files": t.files_analyzed,
11739 "density_str": format!("{density:.1}"),
11740 "most_tested": most_tested,
11741 "langs_with_tests": langs.len(),
11742 "cov_line": cov_line,
11743 "cov_fn": cov_fn,
11744 "cov_branch": cov_branch,
11745 },
11746 "lang_tests": lang_tests,
11747 "cov": cov_arr,
11748 "cov_tiers": {"high": high, "mid": mid, "low": low},
11749 "file_cov": file_cov_arr,
11750 "has_coverage": has_cov,
11751 "submodules": {},
11752 })
11753}
11754
11755#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
11757 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
11758 .language_summaries
11759 .iter()
11760 .filter(|l| l.test_count > 0)
11761 .collect();
11762 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11763 let lang_tests: Vec<serde_json::Value> = langs
11764 .iter()
11765 .map(|l| {
11766 let d = if l.code_lines > 0 {
11767 l.test_count as f64 / l.code_lines as f64 * 1000.0
11768 } else {
11769 0.0
11770 };
11771 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
11772 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
11773 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
11774 })
11775 .collect();
11776 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
11777 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
11778 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
11779 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
11780 let density = if sub.code_lines > 0 {
11781 total_tests as f64 / sub.code_lines as f64 * 1000.0
11782 } else {
11783 0.0
11784 };
11785 let most_tested = langs.first().map_or_else(
11786 || "\u{2014}".to_string(),
11787 |l| l.language.display_name().to_string(),
11788 );
11789 serde_json::json!({
11790 "totals": {
11791 "test_count": total_tests,
11792 "assertions": total_assertions,
11793 "suites": total_suites,
11794 "test_files": test_files_approx,
11795 "total_files": sub.files_analyzed,
11796 "density_str": format!("{density:.1}"),
11797 "most_tested": most_tested,
11798 "langs_with_tests": langs.len(),
11799 "cov_line": "0",
11800 "cov_fn": "0",
11801 "cov_branch": "0",
11802 },
11803 "lang_tests": lang_tests,
11804 "cov": [],
11805 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
11806 "has_coverage": false,
11807 })
11808}
11809
11810fn compute_cov_json_str(run: &AnalysisRun) -> String {
11811 use std::collections::HashMap;
11812 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
11813 for rec in &run.per_file_records {
11814 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
11815 let e = totals.entry(lang.display_name().to_string()).or_default();
11816 e.0 += u64::from(cov.lines_found);
11817 e.1 += u64::from(cov.lines_hit);
11818 }
11819 }
11820 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
11822 .into_iter()
11823 .filter(|(_, (found, _))| *found > 0)
11824 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
11825 .collect();
11826 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
11827 let parts: Vec<String> = pairs
11828 .iter()
11829 .map(|(lang, pct)| {
11830 let name = lang.replace('"', "\\\"");
11831 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
11832 })
11833 .collect();
11834 format!("[{}]", parts.join(","))
11835}
11836
11837fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
11838 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
11839 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
11840}
11841
11842fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
11843 let mut entry = build_test_scope_entry(run);
11844 if !run.submodule_summaries.is_empty() {
11845 let subs: serde_json::Map<String, serde_json::Value> = run
11846 .submodule_summaries
11847 .iter()
11848 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
11849 .collect();
11850 entry["submodules"] = serde_json::Value::Object(subs);
11851 }
11852 entry
11853}
11854
11855fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
11856 let name = l.language.display_name().replace('"', "\\\"");
11857 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
11859 l.test_count as f64 / l.code_lines as f64 * 1000.0
11860 } else {
11861 0.0
11862 };
11863 format!(
11864 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
11865 name = name,
11866 t = l.test_count,
11867 a = l.test_assertion_count,
11868 s = l.test_suite_count,
11869 c = l.code_lines,
11870 d = density,
11871 f = l.files,
11872 )
11873}
11874
11875fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
11876 let Some(r) = run else {
11877 return "[]".to_string();
11878 };
11879 let mut langs: Vec<&sloc_core::LanguageSummary> = r
11880 .totals_by_language
11881 .iter()
11882 .filter(|l| l.test_count > 0)
11883 .collect();
11884 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
11885 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
11886 format!("[{}]", parts.join(","))
11887}
11888
11889async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
11891 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
11892 scope_map.insert(
11893 "__all__".to_string(),
11894 latest_run.map_or_else(
11895 || {
11896 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
11897 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
11898 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
11899 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
11900 "has_coverage":false,"submodules":{}})
11901 },
11902 build_test_scope_entry,
11903 ),
11904 );
11905 let all_roots: Vec<String> = {
11906 let reg = state.registry.lock().await;
11907 let mut seen = std::collections::BTreeSet::new();
11908 reg.entries
11909 .iter()
11910 .flat_map(|e| e.input_roots.iter().cloned())
11911 .filter(|r| seen.insert(r.clone()))
11912 .collect()
11913 };
11914 for root in &all_roots {
11915 let json_path = {
11916 let reg = state.registry.lock().await;
11917 reg.entries
11918 .iter()
11919 .find(|e| e.input_roots.iter().any(|r| r == root))
11920 .and_then(|e| e.json_path.clone())
11921 };
11922 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
11923 let json_str = tokio::fs::read_to_string(&p).await.ok();
11924 json_str
11925 .as_deref()
11926 .and_then(|s| serde_json::from_str(s).ok())
11927 } else {
11928 None
11929 };
11930 if let Some(ref run) = run_for_root {
11931 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
11932 }
11933 }
11934 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
11935}
11936
11937#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
11941 State(state): State<AppState>,
11942 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11943) -> Response {
11944 auto_scan_watched_dirs(&state).await;
11945 let watched_dirs_list: Vec<String> = {
11946 let wd = state.watched_dirs.lock().await;
11947 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11948 };
11949 let latest_run: Option<AnalysisRun> = {
11950 let json_path = {
11951 let reg = state.registry.lock().await;
11952 reg.entries.first().and_then(|e| e.json_path.clone())
11953 };
11954 if let Some(p) = json_path {
11955 let json_str = tokio::fs::read_to_string(&p).await.ok();
11956 json_str
11957 .as_deref()
11958 .and_then(|s| serde_json::from_str(s).ok())
11959 } else {
11960 None
11961 }
11962 };
11963
11964 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
11966
11967 let cov_json: String = latest_run
11969 .as_ref()
11970 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11971 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
11972
11973 let _cov_tier_json: String = latest_run
11975 .as_ref()
11976 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
11977 .map_or_else(
11978 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
11979 compute_cov_tier_json_str,
11980 );
11981
11982 let total_tests: u64 = latest_run
11983 .as_ref()
11984 .map_or(0, |r| r.summary_totals.test_count);
11985 let total_assertions: u64 = latest_run
11986 .as_ref()
11987 .map_or(0, |r| r.summary_totals.test_assertion_count);
11988 let total_suites: u64 = latest_run
11989 .as_ref()
11990 .map_or(0, |r| r.summary_totals.test_suite_count);
11991 let total_code: u64 = latest_run
11992 .as_ref()
11993 .map_or(0, |r| r.summary_totals.code_lines);
11994 let workspace_density: f64 = if total_code > 0 {
11995 total_tests as f64 / total_code as f64 * 1000.0
11996 } else {
11997 0.0
11998 };
11999 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
12000 r.totals_by_language
12001 .iter()
12002 .filter(|l| l.test_count > 0)
12003 .count()
12004 });
12005 let most_tested: String = latest_run
12006 .as_ref()
12007 .and_then(|r| {
12008 r.totals_by_language
12009 .iter()
12010 .filter(|l| l.test_count > 0)
12011 .max_by_key(|l| l.test_count)
12012 })
12013 .map_or_else(
12014 || "\u{2014}".to_string(),
12015 |l| l.language.display_name().to_string(),
12016 );
12017 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
12018 r.per_file_records
12019 .iter()
12020 .filter(|f| f.raw_line_categories.test_count > 0)
12021 .count() as u64
12022 });
12023 let total_files_analyzed: u64 = latest_run
12024 .as_ref()
12025 .map_or(0, |r| r.summary_totals.files_analyzed);
12026 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
12027
12028 let cov_line_pct_str: String = latest_run
12030 .as_ref()
12031 .filter(|r| r.summary_totals.coverage_lines_found > 0)
12032 .map_or_else(
12033 || "0".to_string(),
12034 |r| {
12035 format!(
12036 "{:.1}",
12037 r.summary_totals.coverage_lines_hit as f64
12038 / r.summary_totals.coverage_lines_found as f64
12039 * 100.0
12040 )
12041 },
12042 );
12043 let cov_fn_pct_str: String = latest_run
12044 .as_ref()
12045 .filter(|r| r.summary_totals.coverage_functions_found > 0)
12046 .map_or_else(
12047 || "0".to_string(),
12048 |r| {
12049 format!(
12050 "{:.1}",
12051 r.summary_totals.coverage_functions_hit as f64
12052 / r.summary_totals.coverage_functions_found as f64
12053 * 100.0
12054 )
12055 },
12056 );
12057 let cov_branch_pct_str: String = latest_run
12058 .as_ref()
12059 .filter(|r| r.summary_totals.coverage_branches_found > 0)
12060 .map_or_else(
12061 || "0".to_string(),
12062 |r| {
12063 format!(
12064 "{:.1}",
12065 r.summary_totals.coverage_branches_hit as f64
12066 / r.summary_totals.coverage_branches_found as f64
12067 * 100.0
12068 )
12069 },
12070 );
12071
12072 let cov_no_data_notice = if has_coverage {
12073 String::new()
12074 } else {
12075 String::from(
12076 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
12077<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>
12078<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
12079 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
12080 <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>
12081 <span style="color:var(--muted);font-size:12px;">·</span>
12082 <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>
12083 <span style="color:var(--muted);font-size:12px;">·</span>
12084 <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>
12085</div>
12086<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
12087</div>"#,
12088 )
12089 };
12090
12091 let workspace_density_str = format!("{workspace_density:.1}");
12092 let nonce = &csp_nonce;
12093 let version = env!("CARGO_PKG_VERSION");
12094
12095 let watched_dirs_html: String = if state.server_mode {
12098 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()
12099 } else {
12100 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12101 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12102 .to_string()
12103 } else {
12104 watched_dirs_list
12105 .iter()
12106 .fold(String::new(), |mut s, d| {
12107 use std::fmt::Write as _;
12108 let escaped =
12109 d.replace('&', "&").replace('"', """).replace('<', "<");
12110 write!(
12111 s,
12112 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>"#
12113 ).expect("write to String is infallible");
12114 s
12115 })
12116 };
12117 format!(
12118 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>"#
12119 )
12120 };
12121
12122 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
12124
12125 let html = format!(
12126 r#"<!doctype html>
12127<html lang="en">
12128<head>
12129 <meta charset="utf-8" />
12130 <meta name="viewport" content="width=device-width, initial-scale=1" />
12131 <title>OxideSLOC | Test Metrics</title>
12132 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12133 <style nonce="{nonce}">
12134 :root {{
12135 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12136 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12137 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12138 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12139 --info-bg:#eef3ff; --info-text:#4467d8;
12140 }}
12141 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12142 *{{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;}}
12143 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12144 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12145 .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;}}
12146 @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));}}}}
12147 .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);}}
12148 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12149 .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));}}
12150 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12151 .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;}}
12152 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12153 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12154 @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; }} }}
12155 .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;}}
12156 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12157 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12158 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12159 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12160 .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;}}
12161 .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;}}
12162 .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;}}
12163 .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;}}
12164 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12165 .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);}}
12166 .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;}}
12167 .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;}}
12168 .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;}}
12169 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12170 .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;}}
12171 .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);}}
12172 .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;}}
12173 .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;}}
12174 .tz-select:focus{{border-color:var(--oxide);}}
12175 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12176 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12177 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12178 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12179 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12180 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12181 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12182 .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;}}
12183 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12184 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12185 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12186 .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;}}
12187 .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;}}
12188 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12189 .stat-chip:hover .stat-chip-tip{{opacity:1;}}
12190 .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);}}
12191 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
12192 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
12193 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
12194 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
12195 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
12196 .chart-canvas-wrap{{position:relative;height:280px;}}
12197 .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;}}
12198 .chart-no-data svg{{opacity:0.35;}}
12199 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
12200 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
12201 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
12202 .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;}}
12203 .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;}}
12204 .data-table tr:last-child td{{border-bottom:none;}}
12205 .data-table tbody tr:hover td{{background:var(--surface-2);}}
12206 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
12207 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
12208 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
12209 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
12210 .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;}}
12211 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
12212 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
12213 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
12214 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
12215 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
12216 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
12217 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
12218 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
12219 .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;}}
12220 .chart-select:focus{{border-color:var(--accent);}}
12221 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12222 .trend-canvas-wrap{{position:relative;height:260px;}}
12223 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12224 .site-footer a{{color:var(--muted);}}
12225 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
12226 .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;}}
12227 .btn:hover{{background:var(--surface-2);}}
12228 .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;}}
12229 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12230 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
12231 .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;}}
12232 .scope-sel:focus{{border-color:var(--accent);}}
12233 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
12234 .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;}}
12235 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12236 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12237 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12238 .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;}}
12239 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12240 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12241 .watched-chip-rm:hover{{color:var(--oxide);}}
12242 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12243 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12244 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12245 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12246 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
12247 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
12248 .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;}}
12249 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
12250 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
12251 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
12252 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
12253 .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;}}
12254 .cov-file-search:focus{{border-color:var(--accent);}}
12255 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
12256 .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;}}
12257 body.dark-theme .cov-file-search{{background:var(--surface);}}
12258 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
12259 .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;}}
12260 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12261 .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;}}
12262 .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);}}
12263 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
12264 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
12265 .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;}}
12266 .chart-modal-close:hover{{opacity:.7;}}
12267 body.dark-theme .chart-modal{{background:var(--surface);}}
12268 </style>
12269</head>
12270<body>
12271 <div class="background-watermarks" aria-hidden="true">
12272 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12273 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12274 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12275 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12276 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12277 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12278 </div>
12279 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12280 <div class="top-nav">
12281 <div class="top-nav-inner">
12282 <a class="brand" href="/">
12283 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12284 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
12285 </a>
12286 <div class="nav-right">
12287 <a class="nav-pill" href="/">Home</a>
12288 <div class="nav-dropdown">
12289 <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>
12290 <div class="nav-dropdown-menu">
12291 <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>
12292 </div>
12293 </div>
12294 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12295 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
12296 <div class="nav-dropdown">
12297 <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>
12298 <div class="nav-dropdown-menu">
12299 <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>
12300 </div>
12301 </div>
12302 <div class="server-status-wrap" id="server-status-wrap">
12303 <div class="nav-pill server-online-pill" id="server-status-pill">
12304 <span class="status-dot" id="status-dot"></span>
12305 <span id="server-status-label">Server</span>
12306 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12307 </div>
12308 <div class="server-status-tip">
12309 OxideSLOC is running — accessible on your network.
12310 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12311 </div>
12312 </div>
12313 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12314 <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>
12315 </button>
12316 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12317 <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>
12318 <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>
12319 </button>
12320 </div>
12321 </div>
12322 </div>
12323
12324 <div class="page">
12325 {watched_dirs_html}
12326 <div class="scope-bar">
12327 <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>
12328 <span class="scope-label">Scope</span>
12329 <div class="scope-sel-wrap">
12330 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
12331 <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);">
12332 <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>
12333 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
12334 </div>
12335 </div>
12336 </div>
12337 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12338 <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>
12339 <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>
12340 <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>
12341 <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>
12342 </div>
12343 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
12344 <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>
12345 <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>
12346 <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>
12347 <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>
12348 </div>
12349
12350 <div class="panel" id="viz-panel">
12351 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
12352
12353 <div class="chart-box" style="margin-bottom:18px;">
12354 <div class="chart-box-header">
12355 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
12356 <div style="display:flex;gap:8px;align-items:center;">
12357 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
12358 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12359 </div>
12360 </div>
12361 <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>
12362 <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
12363 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
12364 </div>
12365
12366 <div class="chart-row">
12367 <div class="chart-box">
12368 <div class="chart-box-header">
12369 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
12370 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12371 </div>
12372 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
12373 <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>
12374 </div>
12375 <div class="chart-box">
12376 <div class="chart-box-header">
12377 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
12378 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12379 </div>
12380 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
12381 <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>
12382 </div>
12383 </div>
12384
12385 <div class="chart-row">
12386 <div class="chart-box">
12387 <div class="chart-box-header">
12388 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
12389 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12390 </div>
12391 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
12392 <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>
12393 </div>
12394 <div class="chart-box" id="suites-chart-box">
12395 <div class="chart-box-header">
12396 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
12397 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
12398 </div>
12399 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
12400 <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>
12401 </div>
12402 </div>
12403
12404 <div class="chart-row">
12405 <div class="chart-box">
12406 <div class="chart-box-title">Test Files Breakdown</div>
12407 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
12408 <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>
12409 </div>
12410 <div class="chart-box">
12411 <div class="chart-box-title">Test Composition</div>
12412 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
12413 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
12414 <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>
12415 </div>
12416 </div>
12417 </div>
12418
12419 <div class="panel">
12420 <h1>Test Metrics</h1>
12421 <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>
12422
12423 <div class="section-header">Language Breakdown</div>
12424 {cov_no_data_notice}
12425 <div style="overflow-x:auto;">
12426 <table class="data-table" id="lang-table">
12427 <thead><tr>
12428 <th>Language</th>
12429 <th class="num">Test Fns</th>
12430 <th class="num">Assertions</th>
12431 <th class="num">Suites</th>
12432 <th class="num">Code Lines</th>
12433 <th class="num">Files</th>
12434 <th class="num">Density / 1K</th>
12435 <th>Relative Density</th>
12436 </tr></thead>
12437 <tbody id="lang-tbody"></tbody>
12438 </table>
12439 </div>
12440 </div>
12441
12442 <div class="panel" id="cov-panel" style="display:none;">
12443 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
12444 <div class="cov-gauge-row" id="cov-gauges">
12445 <div class="cov-gauge-card">
12446 <div class="cov-gauge-label">Line Coverage</div>
12447 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
12448 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
12449 <div class="cov-gauge-sub">Lines hit / instrumented</div>
12450 </div>
12451 <div class="cov-gauge-card">
12452 <div class="cov-gauge-label">Function Coverage</div>
12453 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
12454 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
12455 <div class="cov-gauge-sub">Functions hit / found</div>
12456 </div>
12457 <div class="cov-gauge-card">
12458 <div class="cov-gauge-label">Branch Coverage</div>
12459 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
12460 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
12461 <div class="cov-gauge-sub">Branches hit / found</div>
12462 </div>
12463 </div>
12464 <div class="chart-row">
12465 <div class="chart-box">
12466 <div class="chart-box-title">Line Coverage % by Language</div>
12467 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
12468 </div>
12469 <div class="chart-box">
12470 <div class="chart-box-title">Coverage Tier Distribution</div>
12471 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
12472 </div>
12473 </div>
12474
12475 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
12476 <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>
12477 <div class="cov-file-toolbar">
12478 <div class="cov-filter-tabs" id="cov-filter-tabs">
12479 <button class="cov-tab active" data-tier="all">All</button>
12480 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
12481 <button class="cov-tab" data-tier="low">Low (<50%)</button>
12482 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
12483 <button class="cov-tab" data-tier="high">High (≥80%)</button>
12484 </div>
12485 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
12486 </div>
12487 <div style="overflow-x:auto;">
12488 <table class="data-table" id="cov-file-table">
12489 <thead><tr>
12490 <th>File</th>
12491 <th>Lang</th>
12492 <th class="num">Line %</th>
12493 <th class="num">Lines Hit / Found</th>
12494 <th class="num">Fn %</th>
12495 <th class="num">Fns Hit / Found</th>
12496 </tr></thead>
12497 <tbody id="cov-file-tbody"></tbody>
12498 </table>
12499 </div>
12500 <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>
12501 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
12502 </div>
12503
12504 </div>
12505
12506 <footer class="site-footer">
12507 local code analysis - metrics, history and reports
12508 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
12509 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12510 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12511 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12512 · <a href="/api-docs" rel="noopener">REST API</a>
12513 </footer>
12514
12515 <script nonce="{nonce}">
12516 (function() {{
12517 // Theme
12518 var b = document.body;
12519 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12520 var tgl = document.getElementById('theme-toggle');
12521 if (tgl) tgl.addEventListener('click', function() {{
12522 var d = b.classList.toggle('dark-theme');
12523 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12524 }});
12525
12526 // Watermarks
12527 (function() {{
12528 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12529 if (!wms.length) return;
12530 var placed = [];
12531 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;}}
12532 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];}}
12533 var half=Math.floor(wms.length/2);
12534 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;}});
12535 }})();
12536
12537 // Code particles
12538 (function() {{
12539 var container = document.getElementById('code-particles');
12540 if (!container) return;
12541 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
12542 for (var i = 0; i < 36; i++) {{
12543 (function(idx) {{
12544 var el = document.createElement('span');
12545 el.className = 'code-particle';
12546 el.textContent = snippets[idx % snippets.length];
12547 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12548 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12549 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12550 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';
12551 container.appendChild(el);
12552 }})(i);
12553 }}
12554 }})();
12555
12556 // Settings modal
12557 (function() {{
12558 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'}}];
12559 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);}});}}
12560 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12561 var btn=document.getElementById('settings-btn');if(!btn)return;
12562 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12563 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>';
12564 document.body.appendChild(m);
12565 var g=document.getElementById('scheme-grid');
12566 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);}});
12567 var cl=document.getElementById('settings-close');
12568 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');}});
12569 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12570 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12571 }})();
12572
12573 // Watched folder picker
12574 (function() {{
12575 var btn = document.getElementById('add-watched-btn');
12576 if (!btn) return;
12577 btn.addEventListener('click', function() {{
12578 fetch('/pick-directory?kind=reports')
12579 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12580 .then(function(data) {{
12581 if (!data.cancelled && data.selected_path) {{
12582 var form = document.createElement('form');
12583 form.method = 'POST';
12584 form.action = '/watched-dirs/add';
12585 var ri = document.createElement('input');
12586 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12587 var fi = document.createElement('input');
12588 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12589 form.appendChild(ri); form.appendChild(fi);
12590 document.body.appendChild(form);
12591 form.submit();
12592 }}
12593 }})
12594 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12595 }});
12596 }})();
12597 }})();
12598 </script>
12599
12600 <script src="/static/chart.js" nonce="{nonce}"></script>
12601 <script nonce="{nonce}">
12602 (function() {{
12603 var SCOPE_DATA = {scope_data_json};
12604 var currentRoot = '__all__';
12605 var currentSub = '';
12606 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
12607 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
12608 var ALL_CHARTS = [];
12609 var currentLangTests = [];
12610 var currentTrendPts = [];
12611
12612 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();}}
12613 function fmtFull(n){{return Number(n).toLocaleString();}}
12614 function isDark(){{return document.body.classList.contains('dark-theme');}}
12615 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
12616 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
12617 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
12618
12619 function makeDlPlugin(fmtFn, anchor) {{
12620 return {{
12621 afterDatasetsDraw: function(chart) {{
12622 var ctx = chart.ctx;
12623 var tc = txtClr();
12624 chart.data.datasets.forEach(function(ds, di) {{
12625 var meta = chart.getDatasetMeta(di);
12626 meta.data.forEach(function(el, idx) {{
12627 var label = fmtFn(ds.data[idx], di, idx);
12628 if (label == null || label === '') return;
12629 ctx.save();
12630 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
12631 ctx.fillStyle = tc;
12632 if (anchor === 'top') {{
12633 ctx.textAlign = 'center';
12634 ctx.textBaseline = 'bottom';
12635 ctx.fillText(String(label), el.x, el.y - 5);
12636 }} else {{
12637 ctx.textAlign = 'left';
12638 ctx.textBaseline = 'middle';
12639 ctx.fillText(String(label), el.x + 5, el.y);
12640 }}
12641 ctx.restore();
12642 }});
12643 }});
12644 }}
12645 }};
12646 }}
12647
12648 function makeTmOverlay(title, subtitle, h) {{
12649 var overlay = document.createElement('div');
12650 overlay.className = 'chart-modal-overlay';
12651 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
12652 var ch = Math.min(h || 560, maxH);
12653 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
12654 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>';
12655 document.body.appendChild(overlay);
12656 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
12657 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
12658 return document.getElementById('tm-modal-canvas');
12659 }}
12660
12661 function getDataset() {{
12662 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
12663 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
12664 return r;
12665 }}
12666 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
12667
12668 function showNoData(id, show) {{
12669 var el = document.getElementById(id);
12670 if (!el) return;
12671 var wrap = el.previousElementSibling;
12672 el.style.display = show ? '' : 'none';
12673 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
12674 }}
12675
12676 function renderTestCharts(D) {{
12677 currentLangTests = D || [];
12678 testsChart = destroyChart(testsChart);
12679 densityChart = destroyChart(densityChart);
12680 if (!D || !D.length) {{
12681 showNoData('no-data-tests', true);
12682 showNoData('no-data-density', true);
12683 return;
12684 }}
12685 showNoData('no-data-tests', false);
12686 showNoData('no-data-density', false);
12687 var top15 = D.slice(0, 15);
12688 var canvas1 = document.getElementById('canvas-tests');
12689 if (canvas1) {{
12690 testsChart = new Chart(canvas1, {{
12691 type: 'bar',
12692 data: {{
12693 labels: top15.map(function(d){{ return d.lang; }}),
12694 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
12695 }},
12696 options: {{
12697 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12698 layout: {{ padding: {{ right: 64 }} }},
12699 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12700 scales: {{
12701 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12702 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12703 }}
12704 }},
12705 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12706 }});
12707 ALL_CHARTS.push(testsChart);
12708 }}
12709 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
12710 var canvas2 = document.getElementById('canvas-density');
12711 if (canvas2) {{
12712 densityChart = new Chart(canvas2, {{
12713 type: 'bar',
12714 data: {{
12715 labels: topD.map(function(d){{ return d.lang; }}),
12716 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 }}]
12717 }},
12718 options: {{
12719 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12720 layout: {{ padding: {{ right: 64 }} }},
12721 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
12722 scales: {{
12723 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
12724 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12725 }}
12726 }},
12727 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
12728 }});
12729 ALL_CHARTS.push(densityChart);
12730 }}
12731 }}
12732
12733 function renderAssertionsChart(D) {{
12734 assertionsChart = destroyChart(assertionsChart);
12735 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
12736 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
12737 var canvas = document.getElementById('canvas-assertions');
12738 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
12739 showNoData('no-data-assertions', false);
12740 assertionsChart = new Chart(canvas, {{
12741 type: 'bar',
12742 data: {{
12743 labels: top15.map(function(d){{ return d.lang; }}),
12744 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
12745 }},
12746 options: {{
12747 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12748 layout: {{ padding: {{ right: 64 }} }},
12749 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12750 scales: {{
12751 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12752 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12753 }}
12754 }},
12755 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12756 }});
12757 ALL_CHARTS.push(assertionsChart);
12758 }}
12759
12760 function renderSuitesChart(D) {{
12761 suitesChart = destroyChart(suitesChart);
12762 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
12763 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
12764 var canvas = document.getElementById('canvas-suites');
12765 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
12766 showNoData('no-data-suites', false);
12767 suitesChart = new Chart(canvas, {{
12768 type: 'bar',
12769 data: {{
12770 labels: top15.map(function(d){{ return d.lang; }}),
12771 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 }}]
12772 }},
12773 options: {{
12774 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12775 layout: {{ padding: {{ right: 64 }} }},
12776 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
12777 scales: {{
12778 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
12779 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12780 }}
12781 }},
12782 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
12783 }});
12784 ALL_CHARTS.push(suitesChart);
12785 }}
12786
12787 function renderFilesChart(totals) {{
12788 filesChart = destroyChart(filesChart);
12789 var canvas = document.getElementById('canvas-files');
12790 if (!canvas) return;
12791 var testF = totals.test_files || 0;
12792 var totalF = totals.total_files || 0;
12793 var nonTest = Math.max(0, totalF - testF);
12794 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
12795 showNoData('no-data-files', false);
12796 var dark = isDark();
12797 filesChart = new Chart(canvas, {{
12798 type: 'doughnut',
12799 data: {{
12800 labels: ['Test Files', 'Non-Test Files'],
12801 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
12802 }},
12803 options: {{
12804 responsive: true, maintainAspectRatio: false, cutout: '62%',
12805 plugins: {{
12806 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12807 tooltip: {{ callbacks: {{ label: function(ctx) {{
12808 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
12809 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
12810 }} }} }}
12811 }}
12812 }}
12813 }});
12814 ALL_CHARTS.push(filesChart);
12815 }}
12816
12817 function renderCompositionChart(totals) {{
12818 compositionChart = destroyChart(compositionChart);
12819 var canvas = document.getElementById('canvas-composition');
12820 if (!canvas) return;
12821 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
12822 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
12823 showNoData('no-data-composition', false);
12824 compositionChart = new Chart(canvas, {{
12825 type: 'bar',
12826 data: {{
12827 labels: ['Test Functions', 'Assertions', 'Test Suites'],
12828 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
12829 }},
12830 options: {{
12831 responsive: true, maintainAspectRatio: false,
12832 layout: {{ padding: {{ top: 22 }} }},
12833 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
12834 scales: {{
12835 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
12836 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
12837 }}
12838 }},
12839 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
12840 }});
12841 ALL_CHARTS.push(compositionChart);
12842 }}
12843
12844 function renderCovCharts(covD, tiers) {{
12845 covChart = destroyChart(covChart);
12846 tierChart = destroyChart(tierChart);
12847 var covCanvas = document.getElementById('canvas-cov');
12848 if (covCanvas && covD && covD.length) {{
12849 covChart = new Chart(covCanvas, {{
12850 type: 'bar',
12851 data: {{
12852 labels: covD.map(function(d){{ return d.lang; }}),
12853 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 }}]
12854 }},
12855 options: {{
12856 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
12857 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
12858 scales: {{
12859 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
12860 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
12861 }}
12862 }}
12863 }});
12864 ALL_CHARTS.push(covChart);
12865 }}
12866 var tierCanvas = document.getElementById('canvas-cov-tiers');
12867 if (tierCanvas && tiers) {{
12868 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
12869 tierChart = new Chart(tierCanvas, {{
12870 type: 'doughnut',
12871 data: {{
12872 labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
12873 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
12874 }},
12875 options: {{
12876 responsive: true, maintainAspectRatio: false, cutout: '62%',
12877 plugins: {{
12878 legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
12879 tooltip: {{ callbacks: {{ label: function(ctx) {{
12880 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
12881 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
12882 }} }} }}
12883 }}
12884 }}
12885 }});
12886 ALL_CHARTS.push(tierChart);
12887 }}
12888 }}
12889
12890 function buildLangTable(D) {{
12891 var tbody = document.getElementById('lang-tbody');
12892 if (!tbody) return;
12893 if (!D || !D.length) {{
12894 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>';
12895 return;
12896 }}
12897 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
12898 tbody.innerHTML = D.map(function(d) {{
12899 var barW = Math.round(d.density / maxDensity * 120);
12900 return '<tr>' +
12901 '<td><strong>' + d.lang + '</strong></td>' +
12902 '<td class="num">' + fmt(d.tests) + '</td>' +
12903 '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
12904 '<td class="num">' + fmt(d.suites || 0) + '</td>' +
12905 '<td class="num">' + fmt(d.code) + '</td>' +
12906 '<td class="num">' + fmt(d.files) + '</td>' +
12907 '<td class="num">' + d.density.toFixed(2) + '</td>' +
12908 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
12909 '</tr>';
12910 }}).join('');
12911 }}
12912
12913 var covFileData = [];
12914 var covFileTier = 'all';
12915 var covFileSearch = '';
12916
12917 function pctBadge(pct) {{
12918 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
12919 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
12920 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
12921 }}
12922
12923 function buildCovFileTable() {{
12924 var tbody = document.getElementById('cov-file-tbody');
12925 var empty = document.getElementById('cov-file-empty');
12926 var count = document.getElementById('cov-file-count');
12927 if (!tbody) return;
12928 var srch = covFileSearch.toLowerCase();
12929 var filtered = covFileData.filter(function(f) {{
12930 if (covFileTier === 'zero' && f.line_pct > 0) return false;
12931 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
12932 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
12933 if (covFileTier === 'high' && f.line_pct < 80) return false;
12934 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
12935 return true;
12936 }});
12937 if (!filtered.length) {{
12938 tbody.innerHTML = '';
12939 if (empty) empty.style.display = '';
12940 if (count) count.textContent = '';
12941 return;
12942 }}
12943 if (empty) empty.style.display = 'none';
12944 var shown = Math.min(filtered.length, 500);
12945 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
12946 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
12947 var fnCol = f.fn_pct < 0
12948 ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
12949 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
12950 return '<tr>' +
12951 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
12952 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
12953 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
12954 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
12955 fnCol +
12956 '</tr>';
12957 }}).join('');
12958 }}
12959
12960 (function() {{
12961 var tabs = document.getElementById('cov-filter-tabs');
12962 if (tabs) {{
12963 tabs.addEventListener('click', function(e) {{
12964 var btn = e.target.closest('.cov-tab');
12965 if (!btn) return;
12966 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
12967 btn.classList.add('active');
12968 covFileTier = btn.getAttribute('data-tier');
12969 buildCovFileTable();
12970 }});
12971 }}
12972 var srch = document.getElementById('cov-file-search');
12973 if (srch) {{
12974 srch.addEventListener('input', function() {{
12975 covFileSearch = this.value;
12976 buildCovFileTable();
12977 }});
12978 }}
12979 }})();
12980
12981 function updateCovGauges(t) {{
12982 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
12983 var el;
12984 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
12985 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
12986 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
12987 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
12988 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
12989 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
12990 }}
12991
12992 function applyScope() {{
12993 var d = getDataset();
12994 var t = d.totals;
12995 var el;
12996 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
12997 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
12998 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
12999 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
13000 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
13001 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
13002 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
13003 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
13004 renderTestCharts(d.lang_tests);
13005 renderAssertionsChart(d.lang_tests);
13006 renderSuitesChart(d.lang_tests);
13007 renderFilesChart(t);
13008 renderCompositionChart(t);
13009 buildLangTable(d.lang_tests);
13010 var covPanel = document.getElementById('cov-panel');
13011 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
13012 if (d.has_coverage) {{
13013 renderCovCharts(d.cov, d.cov_tiers);
13014 updateCovGauges(t);
13015 covFileData = d.file_cov || [];
13016 covFileTier = 'all';
13017 covFileSearch = '';
13018 var tabs = document.getElementById('cov-filter-tabs');
13019 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
13020 var srch = document.getElementById('cov-file-search');
13021 if (srch) srch.value = '';
13022 buildCovFileTable();
13023 }}
13024 loadTrend();
13025 }}
13026
13027 // Populate scope-root-sel from SCOPE_DATA keys
13028 (function() {{
13029 var sel = document.getElementById('scope-root-sel');
13030 if (!sel) return;
13031 Object.keys(SCOPE_DATA).forEach(function(k) {{
13032 if (k === '__all__') return;
13033 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
13034 }});
13035 }})();
13036
13037 document.getElementById('scope-root-sel').addEventListener('change', function() {{
13038 currentRoot = this.value;
13039 currentSub = '';
13040 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
13041 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
13042 var subWrap = document.getElementById('scope-sub-wrap');
13043 var subSel = document.getElementById('scope-sub-sel');
13044 subSel.innerHTML = '<option value="">Entire project</option>';
13045 if (subNames.length) {{
13046 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
13047 subWrap.style.display = 'flex';
13048 }} else {{
13049 subWrap.style.display = 'none';
13050 }}
13051 applyScope();
13052 }});
13053
13054 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
13055 currentSub = this.value;
13056 applyScope();
13057 }});
13058
13059 function buildTrend(data) {{
13060 var trendCanvas = document.getElementById('canvas-trend');
13061 var trendEmpty = document.getElementById('trend-empty');
13062 var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
13063 pts = pts.slice().reverse();
13064 currentTrendPts = pts;
13065 if (!pts.length) {{
13066 if (trendCanvas) trendCanvas.style.display = 'none';
13067 if (trendEmpty) trendEmpty.style.display = '';
13068 return;
13069 }}
13070 if (trendCanvas) trendCanvas.style.display = '';
13071 if (trendEmpty) trendEmpty.style.display = 'none';
13072 trendChart = destroyChart(trendChart);
13073 if (!trendCanvas) return;
13074 trendChart = new Chart(trendCanvas, {{
13075 type: 'line',
13076 data: {{
13077 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13078 datasets: [{{
13079 label: 'Test Definitions',
13080 data: pts.map(function(d){{ return d.test_count; }}),
13081 borderColor: '#C45C10',
13082 backgroundColor: 'rgba(196,92,16,0.10)',
13083 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13084 pointRadius: 5, fill: true, tension: 0.3
13085 }}]
13086 }},
13087 options: {{
13088 responsive: true, maintainAspectRatio: false,
13089 layout: {{ padding: {{ top: 22 }} }},
13090 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13091 scales: {{
13092 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
13093 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13094 }}
13095 }},
13096 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13097 }});
13098 ALL_CHARTS.push(trendChart);
13099 }}
13100
13101 // ── Full View expand buttons ──────────────────────────────────────────────
13102 (function() {{
13103 var btn = document.getElementById('tests-expand-btn');
13104 if (!btn) return;
13105 btn.addEventListener('click', function() {{
13106 var D = currentLangTests;
13107 if (!D || !D.length) return;
13108 var top15 = D.slice(0, 15);
13109 var h = Math.max(320, top15.length * 36 + 80);
13110 var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
13111 if (!canvas) return;
13112 new Chart(canvas, {{
13113 type: 'bar',
13114 data: {{
13115 labels: top15.map(function(d){{ return d.lang; }}),
13116 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
13117 }},
13118 options: {{
13119 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13120 layout: {{ padding: {{ right: 72 }} }},
13121 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13122 scales: {{
13123 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13124 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13125 }}
13126 }},
13127 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13128 }});
13129 }});
13130 }})();
13131
13132 (function() {{
13133 var btn = document.getElementById('density-expand-btn');
13134 if (!btn) return;
13135 btn.addEventListener('click', function() {{
13136 var D = currentLangTests;
13137 if (!D || !D.length) return;
13138 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
13139 var h = Math.max(320, topD.length * 36 + 80);
13140 var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
13141 if (!canvas) return;
13142 new Chart(canvas, {{
13143 type: 'bar',
13144 data: {{
13145 labels: topD.map(function(d){{ return d.lang; }}),
13146 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 }}]
13147 }},
13148 options: {{
13149 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13150 layout: {{ padding: {{ right: 72 }} }},
13151 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
13152 scales: {{
13153 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
13154 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13155 }}
13156 }},
13157 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
13158 }});
13159 }});
13160 }})();
13161
13162 (function() {{
13163 var btn = document.getElementById('trend-expand-btn');
13164 if (!btn) return;
13165 btn.addEventListener('click', function() {{
13166 var pts = currentTrendPts;
13167 if (!pts || !pts.length) return;
13168 var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
13169 if (!canvas) return;
13170 new Chart(canvas, {{
13171 type: 'line',
13172 data: {{
13173 labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
13174 datasets: [{{
13175 label: 'Test Definitions',
13176 data: pts.map(function(d){{ return d.test_count; }}),
13177 borderColor: '#C45C10',
13178 backgroundColor: 'rgba(196,92,16,0.10)',
13179 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
13180 pointRadius: 5, fill: true, tension: 0.3
13181 }}]
13182 }},
13183 options: {{
13184 responsive: true, maintainAspectRatio: false,
13185 layout: {{ padding: {{ top: 22 }} }},
13186 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
13187 scales: {{
13188 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
13189 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
13190 }}
13191 }},
13192 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
13193 }});
13194 }});
13195 }})();
13196
13197 (function() {{
13198 var btn = document.getElementById('assertions-expand-btn');
13199 if (!btn) return;
13200 btn.addEventListener('click', function() {{
13201 var D = currentLangTests;
13202 if (!D || !D.length) return;
13203 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
13204 if (!top15.length) return;
13205 var h = Math.max(320, top15.length * 36 + 80);
13206 var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
13207 if (!canvas) return;
13208 new Chart(canvas, {{
13209 type: 'bar',
13210 data: {{
13211 labels: top15.map(function(d){{ return d.lang; }}),
13212 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
13213 }},
13214 options: {{
13215 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13216 layout: {{ padding: {{ right: 72 }} }},
13217 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13218 scales: {{
13219 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13220 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13221 }}
13222 }},
13223 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13224 }});
13225 }});
13226 }})();
13227
13228 (function() {{
13229 var btn = document.getElementById('suites-expand-btn');
13230 if (!btn) return;
13231 btn.addEventListener('click', function() {{
13232 var D = currentLangTests;
13233 if (!D || !D.length) return;
13234 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
13235 if (!top15.length) return;
13236 var h = Math.max(320, top15.length * 36 + 80);
13237 var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
13238 if (!canvas) return;
13239 new Chart(canvas, {{
13240 type: 'bar',
13241 data: {{
13242 labels: top15.map(function(d){{ return d.lang; }}),
13243 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 }}]
13244 }},
13245 options: {{
13246 responsive: true, maintainAspectRatio: false, indexAxis: 'y',
13247 layout: {{ padding: {{ right: 72 }} }},
13248 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
13249 scales: {{
13250 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
13251 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
13252 }}
13253 }},
13254 plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
13255 }});
13256 }});
13257 }})();
13258
13259 function loadTrend() {{
13260 var url = '/api/metrics/history?limit=100';
13261 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
13262 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
13263 buildTrend(data);
13264 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
13265 var btn = document.getElementById('multi-compare-trend-btn');
13266 if (btn) {{
13267 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
13268 if (ids.length >= 2) {{
13269 btn.style.display = '';
13270 btn.onclick = function() {{
13271 // Reverse so oldest first (API returns newest first).
13272 var sorted = ids.slice().reverse();
13273 if (sorted.length === 2) {{
13274 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
13275 }} else {{
13276 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
13277 }}
13278 }};
13279 }} else {{
13280 btn.style.display = 'none';
13281 }}
13282 }}
13283 }}).catch(function(){{
13284 var trendEmpty = document.getElementById('trend-empty');
13285 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
13286 }});
13287 }}
13288
13289 // Re-render charts on theme toggle
13290 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
13291 setTimeout(function() {{
13292 ALL_CHARTS.forEach(function(c) {{
13293 if (c && c.options && c.options.scales) {{
13294 Object.values(c.options.scales).forEach(function(ax) {{
13295 if (ax.grid) ax.grid.color = clr();
13296 if (ax.ticks) ax.ticks.color = txtClr();
13297 }});
13298 c.update();
13299 }}
13300 }});
13301 }}, 80);
13302 }});
13303
13304 applyScope();
13305 }})();
13306 </script>
13307 <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>
13308</body>
13309</html>"#,
13310 );
13311 Html(html).into_response()
13312}
13313
13314#[derive(Deserialize)]
13321struct EmbedQuery {
13322 run_id: Option<String>,
13323 theme: Option<String>,
13324}
13325
13326async fn embed_handler(
13327 State(state): State<AppState>,
13328 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13329 Query(query): Query<EmbedQuery>,
13330) -> Response {
13331 let entry = {
13332 let reg = state.registry.lock().await;
13333 query.run_id.as_ref().map_or_else(
13334 || reg.entries.first().cloned(),
13335 |id| reg.find_by_run_id(id).cloned(),
13336 )
13337 };
13338
13339 let Some(entry) = entry else {
13340 return Html(
13341 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
13342 .to_string(),
13343 )
13344 .into_response();
13345 };
13346
13347 let dark = query.theme.as_deref() == Some("dark");
13348 let languages: Vec<(String, u64, u64)> = entry
13349 .json_path
13350 .as_ref()
13351 .and_then(|p| read_json(p).ok())
13352 .map(|run| {
13353 run.totals_by_language
13354 .iter()
13355 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
13356 .collect()
13357 })
13358 .unwrap_or_default();
13359
13360 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
13361}
13362
13363fn render_embed_widget(
13364 entry: &RegistryEntry,
13365 languages: &[(String, u64, u64)],
13366 dark: bool,
13367 csp_nonce: &str,
13368) -> String {
13369 let s = &entry.summary;
13370 let total = s.code_lines + s.comment_lines + s.blank_lines;
13371 let code_pct = s
13372 .code_lines
13373 .checked_mul(100)
13374 .and_then(|n| n.checked_div(total))
13375 .unwrap_or(0);
13376
13377 let (bg, fg, surface, muted, border) = if dark {
13378 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
13379 } else {
13380 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
13381 };
13382
13383 let mut lang_rows = String::new();
13384 for (name, files, code) in languages {
13385 write!(
13386 lang_rows,
13387 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
13388 escape_html(name),
13389 format_number(*files),
13390 format_number(*code),
13391 )
13392 .ok();
13393 }
13394
13395 let lang_table = if lang_rows.is_empty() {
13396 String::new()
13397 } else {
13398 format!(
13399 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
13400 )
13401 };
13402
13403 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
13404 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
13405 let project_esc = escape_html(&entry.project_label);
13406 let code_lines = format_number(s.code_lines);
13407 let comment_lines = format_number(s.comment_lines);
13408 let files = format_number(s.files_analyzed);
13409 let code_raw = s.code_lines;
13410 let comment_raw = s.comment_lines;
13411 let blank_raw = s.blank_lines;
13412
13413 format!(
13414 r#"<!doctype html>
13415<html lang="en">
13416<head>
13417 <meta charset="utf-8">
13418 <meta name="viewport" content="width=device-width,initial-scale=1">
13419 <title>OxideSLOC — {project_esc}</title>
13420 <script src="/static/chart.js"></script>
13421 <style nonce="{csp_nonce}">
13422 *{{box-sizing:border-box;margin:0;padding:0}}
13423 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
13424 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
13425 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
13426 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
13427 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
13428 .card .v{{font-size:18px;font-weight:700}}
13429 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
13430 .row{{display:flex;gap:12px;align-items:flex-start}}
13431 .pie{{width:120px;height:120px;flex-shrink:0}}
13432 .lt{{border-collapse:collapse;width:100%;flex:1}}
13433 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
13434 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
13435 .n{{text-align:right}}
13436 .footer{{margin-top:10px;color:{muted};font-size:10px}}
13437 </style>
13438</head>
13439<body>
13440 <h2>{project_esc}</h2>
13441 <div class="sub">{timestamp} · run {run_short}</div>
13442 <div class="cards">
13443 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
13444 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
13445 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
13446 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
13447 </div>
13448 <div class="row">
13449 <canvas class="pie" id="c"></canvas>
13450 {lang_table}
13451 </div>
13452 <div class="footer">oxide-sloc</div>
13453 <script nonce="{csp_nonce}">
13454 new Chart(document.getElementById('c'),{{
13455 type:'doughnut',
13456 data:{{
13457 labels:['Code','Comments','Blank'],
13458 datasets:[{{
13459 data:[{code_raw},{comment_raw},{blank_raw}],
13460 backgroundColor:['#4a78ee','#b35428','#aaa'],
13461 borderWidth:0
13462 }}]
13463 }},
13464 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
13465 }});
13466 </script>
13467</body>
13468</html>"#
13469 )
13470}
13471
13472fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
13477 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
13478 OnceLock::new();
13479 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
13480 let mut guard = map
13481 .lock()
13482 .unwrap_or_else(std::sync::PoisonError::into_inner);
13483 guard
13484 .entry(dir.to_path_buf())
13485 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
13486 .clone()
13487}
13488
13489#[allow(clippy::too_many_lines)]
13490fn persist_run_artifacts(
13491 run: &sloc_core::AnalysisRun,
13492 report_html: &str,
13493 run_dir: &Path,
13494 report_title: &str,
13495 file_stem: &str,
13496 result_context: RunResultContext,
13497) -> Result<(RunArtifacts, PendingPdf)> {
13498 let dir_lock = output_dir_lock(run_dir);
13501 let _dir_guard = dir_lock
13502 .lock()
13503 .unwrap_or_else(std::sync::PoisonError::into_inner);
13504
13505 let html_dir = run_dir.join("html");
13507 let pdf_dir = run_dir.join("pdf");
13508 let excel_dir = run_dir.join("excel");
13509 let json_dir = run_dir.join("json");
13510 let submodules_dir = run_dir.join("submodules");
13511 for dir in &[
13512 run_dir,
13513 &html_dir,
13514 &pdf_dir,
13515 &excel_dir,
13516 &json_dir,
13517 &submodules_dir,
13518 ] {
13519 fs::create_dir_all(dir)
13520 .with_context(|| format!("failed to create directory {}", dir.display()))?;
13521 }
13522
13523 let html_path = {
13525 let path = html_dir.join(format!("report_{file_stem}.html"));
13526 fs::write(&path, report_html)
13527 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
13528 Some(path)
13529 };
13530
13531 let json_path = {
13533 let path = json_dir.join(format!("result_{file_stem}.json"));
13534 let json = serde_json::to_string_pretty(run)
13535 .context("failed to serialize analysis run to JSON")?;
13536 fs::write(&path, json)
13537 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
13538 Some(path)
13539 };
13540
13541 let (pdf_path, pending_pdf) = {
13543 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
13544 match write_pdf_from_run(run, &pdf_dest) {
13545 Ok(()) => {
13546 eprintln!(
13547 "[oxide-sloc][pdf] native PDF written to {}",
13548 pdf_dest.display()
13549 );
13550 (Some(pdf_dest), None)
13551 }
13552 Err(native_err) => {
13553 eprintln!(
13554 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
13555 );
13556 let source_html_path = html_path
13557 .as_ref()
13558 .expect("html_path always Some here")
13559 .clone();
13560 let pending = Some((source_html_path, pdf_dest.clone(), false));
13561 (Some(pdf_dest), pending)
13562 }
13563 }
13564 };
13565
13566 let csv_path = {
13568 let path = excel_dir.join(format!("report_{file_stem}.csv"));
13569 if let Err(e) = sloc_report::write_csv(run, &path) {
13570 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
13571 None
13572 } else {
13573 Some(path)
13574 }
13575 };
13576
13577 let xlsx_path = {
13578 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
13579 if let Err(e) = sloc_report::write_xlsx(run, &path) {
13580 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
13581 None
13582 } else {
13583 Some(path)
13584 }
13585 };
13586
13587 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
13589
13590 if run.effective_configuration.discovery.submodule_breakdown {
13592 let run_id = &run.tool.run_id;
13593 for s in &run.submodule_summaries {
13594 build_submodule_row(s, run, run_id, run_dir);
13595 }
13596 }
13597
13598 generate_offline_index(
13600 run,
13601 run_dir,
13602 file_stem,
13603 html_path.as_deref(),
13604 pdf_path.as_deref(),
13605 json_path.as_deref(),
13606 scan_config_path.as_deref(),
13607 &result_context,
13608 );
13609
13610 Ok((
13611 RunArtifacts {
13612 output_dir: run_dir.to_path_buf(),
13613 html_path,
13614 pdf_path,
13615 json_path,
13616 csv_path,
13617 xlsx_path,
13618 scan_config_path,
13619 report_title: report_title.to_string(),
13620 result_context,
13621 },
13622 pending_pdf,
13623 ))
13624}
13625
13626#[allow(clippy::too_many_arguments)]
13629#[allow(clippy::too_many_lines)]
13630#[allow(clippy::similar_names)]
13631fn generate_offline_index(
13632 run: &sloc_core::AnalysisRun,
13633 run_dir: &Path,
13634 file_stem: &str,
13635 html_path: Option<&Path>,
13636 pdf_path: Option<&Path>,
13637 json_path: Option<&Path>,
13638 scan_config_path: Option<&Path>,
13639 result_context: &RunResultContext,
13640) {
13641 let prev_entry = &result_context.prev_entry;
13642 let prev_scan_count = result_context.prev_scan_count;
13643 let project_path = &result_context.project_path;
13644
13645 let scan_delta = prev_entry.as_ref().and_then(|prev| {
13646 prev.json_path
13647 .as_ref()
13648 .and_then(|p| read_json(p).ok())
13649 .map(|prev_run| compute_delta(&prev_run, run))
13650 });
13651
13652 let files_analyzed = run.per_file_records.len() as u64;
13653 let files_skipped = run.skipped_file_records.len() as u64;
13654 let physical_lines = run
13655 .totals_by_language
13656 .iter()
13657 .map(|r| r.total_physical_lines)
13658 .sum::<u64>();
13659 let code_lines = run
13660 .totals_by_language
13661 .iter()
13662 .map(|r| r.code_lines)
13663 .sum::<u64>();
13664 let comment_lines = run
13665 .totals_by_language
13666 .iter()
13667 .map(|r| r.comment_lines)
13668 .sum::<u64>();
13669 let blank_lines = run
13670 .totals_by_language
13671 .iter()
13672 .map(|r| r.blank_lines)
13673 .sum::<u64>();
13674 let mixed_lines = run
13675 .totals_by_language
13676 .iter()
13677 .map(|r| r.mixed_lines_separate)
13678 .sum::<u64>();
13679 let functions = run
13680 .totals_by_language
13681 .iter()
13682 .map(|r| r.functions)
13683 .sum::<u64>();
13684 let classes = run
13685 .totals_by_language
13686 .iter()
13687 .map(|r| r.classes)
13688 .sum::<u64>();
13689 let variables = run
13690 .totals_by_language
13691 .iter()
13692 .map(|r| r.variables)
13693 .sum::<u64>();
13694 let imports = run
13695 .totals_by_language
13696 .iter()
13697 .map(|r| r.imports)
13698 .sum::<u64>();
13699
13700 let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
13701 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
13702 let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
13703 let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
13704 let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
13705 let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
13706 let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
13707 let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
13708
13709 let (delta_fa_str, delta_fa_class) =
13710 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
13711 let (delta_fs_str, delta_fs_class) =
13712 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
13713 let (delta_pl_str, delta_pl_class) =
13714 summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
13715 let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
13716 let (delta_cml_str, delta_cml_class) =
13717 summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
13718 let (delta_bl_str, delta_bl_class) =
13719 summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
13720
13721 let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
13722 let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
13723 let (delta_lines_net_str, delta_lines_net_class) =
13724 match (delta_lines_added, delta_lines_removed) {
13725 (Some(a), Some(r)) => {
13726 let net = a - r;
13727 (fmt_delta(net), delta_class(net).to_string())
13728 }
13729 _ => ("\u{2014}".to_string(), "na".to_string()),
13730 };
13731
13732 let git_commit_url = run
13733 .git_remote_url
13734 .as_deref()
13735 .zip(run.git_commit_long.as_deref())
13736 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
13737 let git_branch_url = run
13738 .git_remote_url
13739 .as_deref()
13740 .zip(run.git_branch.as_deref())
13741 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
13742 let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
13743 format!(
13744 "{} / {}",
13745 run.environment.initiator_username, run.environment.initiator_hostname
13746 )
13747 });
13748
13749 let make_rel = |p: Option<&Path>| -> Option<String> {
13751 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
13752 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
13753 };
13754
13755 let run_id = &run.tool.run_id;
13756
13757 let submodule_rows: Vec<SubmoduleRow> = run
13759 .submodule_summaries
13760 .iter()
13761 .map(|s| {
13762 let safe = sanitize_project_label(&s.name);
13763 let key = format!("sub_{safe}");
13764 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
13765 SubmoduleRow {
13766 name: s.name.clone(),
13767 relative_path: s.relative_path.clone(),
13768 files_analyzed: s.files_analyzed,
13769 code_lines: s.code_lines,
13770 comment_lines: s.comment_lines,
13771 blank_lines: s.blank_lines,
13772 total_physical_lines: s.total_physical_lines,
13773 html_url: if sub_path.exists() {
13774 Some(format!("submodules/{key}.html"))
13775 } else {
13776 None
13777 },
13778 }
13779 })
13780 .collect();
13781
13782 let lang_chart_json = {
13783 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
13784 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
13785 let entries: Vec<String> = langs
13786 .into_iter()
13787 .take(12)
13788 .map(|l| {
13789 let name = l.language.display_name()
13790 .replace('\\', "\\\\")
13791 .replace('"', "\\\"");
13792 format!(
13793 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
13794 name, l.code_lines, l.comment_lines, l.blank_lines,
13795 l.total_physical_lines, l.functions, l.classes,
13796 l.variables, l.imports, l.files
13797 )
13798 })
13799 .collect();
13800 format!("[{}]", entries.join(","))
13801 };
13802
13803 let scan_config_rel =
13804 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
13805
13806 let template = ResultTemplate {
13807 version: env!("CARGO_PKG_VERSION"),
13808 report_title: run.effective_configuration.reporting.report_title.clone(),
13809 project_path: project_path.clone(),
13810 output_dir: display_path(run_dir),
13811 run_id: run_id.clone(),
13812 run_id_short: run_id
13813 .split('-')
13814 .next_back()
13815 .unwrap_or(run_id)
13816 .chars()
13817 .take(7)
13818 .collect(),
13819 files_analyzed,
13820 files_skipped,
13821 physical_lines,
13822 code_lines,
13823 comment_lines,
13824 blank_lines,
13825 mixed_lines,
13826 functions,
13827 classes,
13828 variables,
13829 imports,
13830 html_url: make_rel(html_path),
13831 pdf_url: make_rel(pdf_path),
13832 json_url: make_rel(json_path),
13833 html_download_url: make_rel(html_path),
13834 pdf_download_url: make_rel(pdf_path),
13835 json_download_url: make_rel(json_path),
13836 html_path: html_path.map(display_path),
13837 json_path: json_path.map(display_path),
13838 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
13839 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
13840 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
13841 prev_fa_str,
13842 prev_fs_str,
13843 prev_pl_str,
13844 prev_cl_str,
13845 prev_cml_str,
13846 prev_bl_str,
13847 delta_fa_str,
13848 delta_fa_class: delta_fa_class.to_string(),
13849 delta_fs_str,
13850 delta_fs_class: delta_fs_class.to_string(),
13851 delta_pl_str,
13852 delta_pl_class: delta_pl_class.to_string(),
13853 delta_cl_str,
13854 delta_cl_class: delta_cl_class.to_string(),
13855 delta_cml_str,
13856 delta_cml_class: delta_cml_class.to_string(),
13857 delta_bl_str,
13858 delta_bl_class: delta_bl_class.to_string(),
13859 delta_lines_added,
13860 delta_lines_removed,
13861 delta_lines_net_str,
13862 delta_lines_net_class,
13863 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
13864 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
13865 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
13866 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
13867 delta_unmodified_lines: scan_delta.as_ref().map(|d| {
13868 d.file_deltas
13869 .iter()
13870 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
13871 .map(|f| {
13872 #[allow(clippy::cast_sign_loss)]
13873 let n = f.current_code as u64;
13874 n
13875 })
13876 .sum()
13877 }),
13878 git_branch: run.git_branch.clone(),
13879 git_branch_url,
13880 git_commit: run.git_commit_short.clone(),
13881 git_commit_long: run.git_commit_long.clone(),
13882 git_author: run.git_commit_author.clone(),
13883 git_commit_url,
13884 scan_performed_by,
13885 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
13886 os_display: format!(
13887 "{} / {}",
13888 run.environment.operating_system, run.environment.architecture
13889 ),
13890 test_count: run.summary_totals.test_count,
13891 current_scan_number: prev_scan_count + 1,
13892 prev_scan_count,
13893 submodule_rows,
13894 pdf_generating: false,
13895 scan_config_url: scan_config_rel,
13896 lang_chart_json,
13897 scatter_chart_json: String::new(),
13898 semantic_chart_json: String::new(),
13899 submodule_chart_json: String::new(),
13900 has_submodule_data: !run.submodule_summaries.is_empty(),
13901 has_semantic_data: run
13902 .totals_by_language
13903 .iter()
13904 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
13905 csp_nonce: String::new(),
13906 confluence_configured: false,
13907 server_mode: false,
13908 report_header_footer: run
13909 .effective_configuration
13910 .reporting
13911 .report_header_footer
13912 .clone(),
13913 is_offline: true,
13914 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
13915 lsloc: run.summary_totals.lsloc,
13916 uloc: run.uloc,
13917 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
13918 duplicate_group_count: run.duplicate_groups.len(),
13919 has_cocomo: run.cocomo.is_some(),
13920 cocomo_effort_str: run
13921 .cocomo
13922 .as_ref()
13923 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
13924 cocomo_duration_str: run
13925 .cocomo
13926 .as_ref()
13927 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
13928 cocomo_staff_str: run
13929 .cocomo
13930 .as_ref()
13931 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
13932 cocomo_ksloc_str: run
13933 .cocomo
13934 .as_ref()
13935 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
13936 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
13937 || "Organic".to_string(),
13938 |c| {
13939 use sloc_core::CocomoMode;
13940 match c.mode {
13941 CocomoMode::Organic => "Organic",
13942 CocomoMode::SemiDetached => "Semi-detached",
13943 CocomoMode::Embedded => "Embedded",
13944 }
13945 .to_string()
13946 },
13947 ),
13948 cocomo_mode_tooltip: run.cocomo.as_ref().map_or(String::new(), |c| {
13949 use sloc_core::CocomoMode;
13950 match c.mode {
13951 CocomoMode::Organic => {
13952 "Organic: A small team working on a well-understood \
13953 project in a familiar environment with minimal external constraints. \
13954 Suited for internal tools, utilities, and projects with stable requirements. \
13955 Effort = 2.4 \u{00D7} KSLOC^1.05."
13956 }
13957 CocomoMode::SemiDetached => {
13958 "Semi-detached: A mixed team with varying experience \
13959 tackling a project with moderate novelty and some rigid constraints. \
13960 Typical for compilers, transaction systems, and batch processors. \
13961 Effort = 3.0 \u{00D7} KSLOC^1.12."
13962 }
13963 CocomoMode::Embedded => {
13964 "Embedded: Tight hardware, software, or operational \
13965 constraints requiring significant innovation and deep integration work. \
13966 Typical for real-time control systems and safety-critical software. \
13967 Effort = 3.6 \u{00D7} KSLOC^1.20."
13968 }
13969 }
13970 .to_string()
13971 }),
13972 complexity_alert: 0,
13973 };
13974
13975 if let Ok(html) = template.render() {
13976 let index_path = run_dir.join("index.html");
13977 if let Err(e) = fs::write(&index_path, html) {
13978 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
13979 }
13980 }
13981}
13982
13983fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
13986 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
13988 return Some(found);
13989 }
13990 find_scan_config_in_dir_flat(dir)
13992}
13993
13994fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
13995 let exact = dir.join("scan-config.json");
13996 if exact.exists() {
13997 return Some(exact);
13998 }
13999 fs::read_dir(dir).ok().and_then(|entries| {
14000 entries
14001 .filter_map(std::result::Result::ok)
14002 .find(|e| {
14003 let name = e.file_name();
14004 let name = name.to_string_lossy();
14005 name.starts_with("scan-config") && name.ends_with(".json")
14006 })
14007 .map(|e| e.path())
14008 })
14009}
14010
14011#[derive(Deserialize)]
14016struct ExportPdfRequest {
14017 html: String,
14018 #[serde(default)]
14019 filename: Option<String>,
14020}
14021
14022async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
14023 let html_content = body.html;
14024 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
14025 if html_content.is_empty() {
14026 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
14027 }
14028 let tmp_dir = std::env::temp_dir();
14030 let html_path = tmp_dir.join(format!(
14031 "sloc-export-{}.html",
14032 uuid::Uuid::new_v4().simple()
14033 ));
14034 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
14035 if let Err(e) = std::fs::write(&html_path, &html_content) {
14036 return (
14037 StatusCode::INTERNAL_SERVER_ERROR,
14038 format!("Failed to write temp HTML: {e}"),
14039 )
14040 .into_response();
14041 }
14042 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
14043 let _ = std::fs::remove_file(&html_path);
14044 if let Err(e) = pdf_result {
14045 let _ = std::fs::remove_file(&pdf_path);
14046 return (
14047 StatusCode::INTERNAL_SERVER_ERROR,
14048 format!("PDF generation failed: {e}"),
14049 )
14050 .into_response();
14051 }
14052 let pdf_bytes = match std::fs::read(&pdf_path) {
14053 Ok(b) => b,
14054 Err(e) => {
14055 let _ = std::fs::remove_file(&pdf_path);
14056 return (
14057 StatusCode::INTERNAL_SERVER_ERROR,
14058 format!("Failed to read PDF: {e}"),
14059 )
14060 .into_response();
14061 }
14062 };
14063 let _ = std::fs::remove_file(&pdf_path);
14064 let safe_name: String = filename
14065 .chars()
14066 .map(|c| {
14067 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
14068 c
14069 } else {
14070 '_'
14071 }
14072 })
14073 .collect();
14074 let disposition = format!("attachment; filename=\"{safe_name}\"");
14075 (
14076 [
14077 (header::CONTENT_TYPE, "application/pdf".to_string()),
14078 (header::CONTENT_DISPOSITION, disposition),
14079 ],
14080 pdf_bytes,
14081 )
14082 .into_response()
14083}
14084
14085async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
14086 let toml_str = match toml::to_string_pretty(&state.base_config) {
14087 Ok(s) => s,
14088 Err(e) => {
14089 return (
14090 StatusCode::INTERNAL_SERVER_ERROR,
14091 format!("serialization error: {e}"),
14092 )
14093 .into_response();
14094 }
14095 };
14096 (
14097 [
14098 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
14099 (
14100 header::CONTENT_DISPOSITION,
14101 "attachment; filename=\".oxide-sloc.toml\"",
14102 ),
14103 ],
14104 toml_str,
14105 )
14106 .into_response()
14107}
14108
14109#[derive(Serialize)]
14110struct OkResponse {
14111 ok: bool,
14112}
14113
14114#[derive(Serialize)]
14115struct SaveProfileResponse {
14116 ok: bool,
14117 id: String,
14118}
14119
14120#[derive(Serialize)]
14121struct ProfileListResponse {
14122 profiles: Vec<ScanProfile>,
14123}
14124
14125#[derive(Serialize)]
14126struct ImportConfigResponse {
14127 ok: bool,
14128 config: sloc_config::AppConfig,
14129}
14130
14131#[derive(Deserialize)]
14132struct ImportConfigBody {
14133 toml: String,
14134}
14135
14136async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
14137 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
14138 Ok(config) => {
14139 if let Err(e) = config.validate() {
14140 return error::unprocessable_entity(&e.to_string());
14141 }
14142 Json(ImportConfigResponse { ok: true, config }).into_response()
14143 }
14144 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
14145 }
14146}
14147
14148async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
14151 let store = state.scan_profiles.lock().await;
14152 Json(ProfileListResponse {
14153 profiles: store.profiles.clone(),
14154 })
14155}
14156
14157#[derive(Deserialize)]
14158struct SaveScanProfileBody {
14159 name: String,
14160 params: serde_json::Value,
14161}
14162
14163async fn api_save_scan_profile(
14164 State(state): State<AppState>,
14165 Json(body): Json<SaveScanProfileBody>,
14166) -> impl IntoResponse {
14167 if body.name.trim().is_empty() {
14168 return error::bad_request("name must not be empty");
14169 }
14170
14171 let id = uuid::Uuid::new_v4().to_string();
14172 let profile = ScanProfile {
14173 id: id.clone(),
14174 name: body.name.trim().to_string(),
14175 created_at: chrono::Utc::now().to_rfc3339(),
14176 params: body.params,
14177 };
14178
14179 let mut store = state.scan_profiles.lock().await;
14180 store.profiles.push(profile);
14181 if let Err(e) = store.save(&state.scan_profiles_path) {
14182 tracing::warn!("failed to persist scan profiles: {e}");
14183 }
14184 drop(store);
14185
14186 (
14187 StatusCode::CREATED,
14188 Json(SaveProfileResponse { ok: true, id }),
14189 )
14190 .into_response()
14191}
14192
14193async fn api_delete_scan_profile(
14194 State(state): State<AppState>,
14195 AxumPath(id): AxumPath<String>,
14196) -> impl IntoResponse {
14197 let mut store = state.scan_profiles.lock().await;
14198 let before = store.profiles.len();
14199 store.profiles.retain(|p| p.id != id);
14200 if store.profiles.len() == before {
14201 drop(store);
14202 return error::not_found("profile not found");
14203 }
14204 if let Err(e) = store.save(&state.scan_profiles_path) {
14205 tracing::warn!("failed to persist scan profiles: {e}");
14206 }
14207 drop(store);
14208 Json(OkResponse { ok: true }).into_response()
14209}
14210
14211fn resolve_output_root(raw: Option<&str>) -> PathBuf {
14212 let value = raw.unwrap_or("out/web").trim();
14213 let path = if value.is_empty() {
14214 PathBuf::from("out/web")
14215 } else {
14216 PathBuf::from(value)
14217 };
14218
14219 if path.is_absolute() {
14220 path
14221 } else {
14222 workspace_root().join(path)
14223 }
14224}
14225
14226fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
14228 std::env::var("SLOC_GIT_CLONES_DIR")
14229 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
14230}
14231
14232pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
14235 let safe: String = repo_url
14236 .chars()
14237 .map(|c| {
14238 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
14239 c
14240 } else {
14241 '_'
14242 }
14243 })
14244 .take(80)
14245 .collect();
14246 clones_dir.join(safe)
14247}
14248
14249pub(crate) fn scan_path_to_artifacts(
14252 scan_path: &Path,
14253 base_config: &AppConfig,
14254 label: &str,
14255) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
14256 let mut config = base_config.clone();
14257 config.discovery.root_paths = vec![scan_path.to_path_buf()];
14258 label.clone_into(&mut config.reporting.report_title);
14259 let run = analyze(&config, "git", None, None)?;
14260 let html = render_html(&run)?;
14261 let run_id = run.tool.run_id.clone();
14262 let project_label = sanitize_project_label(label);
14263 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
14264 let file_stem = {
14265 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
14266 if commit.is_empty() {
14267 project_label
14268 } else {
14269 format!("{project_label}_{commit}")
14270 }
14271 };
14272 let (artifacts, _pending_pdf) = persist_run_artifacts(
14273 &run,
14274 &html,
14275 &output_dir,
14276 label,
14277 &file_stem,
14278 RunResultContext::default(),
14279 )?;
14280 Ok((run_id, artifacts, run))
14281}
14282
14283async fn restart_poll_schedules(state: &AppState) {
14285 let store = state.schedules.lock().await;
14286 let poll_schedules: Vec<_> = store
14287 .schedules
14288 .iter()
14289 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
14290 .cloned()
14291 .collect();
14292 drop(store);
14293 for schedule in poll_schedules {
14294 let interval = schedule.interval_secs.unwrap_or(300);
14295 let st = state.clone();
14296 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
14297 }
14298}
14299
14300fn split_patterns(raw: Option<&str>) -> Vec<String> {
14301 raw.unwrap_or("")
14302 .lines()
14303 .flat_map(|line| line.split(','))
14304 .map(str::trim)
14305 .filter(|part| !part.is_empty())
14306 .map(ToOwned::to_owned)
14307 .collect()
14308}
14309
14310#[must_use]
14311pub fn build_sub_run(
14312 parent: &AnalysisRun,
14313 sub: &sloc_core::SubmoduleSummary,
14314 parent_path: &str,
14315) -> AnalysisRun {
14316 let sub_files: Vec<_> = parent
14317 .per_file_records
14318 .iter()
14319 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
14320 .cloned()
14321 .collect();
14322 let mut config = parent.effective_configuration.clone();
14323 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
14324
14325 let mut functions = 0u64;
14327 let mut classes = 0u64;
14328 let mut variables = 0u64;
14329 let mut imports = 0u64;
14330 let mut test_count = 0u64;
14331 let mut test_assertion_count = 0u64;
14332 let mut test_suite_count = 0u64;
14333 let mut mixed_lines_separate = 0u64;
14334 let mut coverage_lines_found = 0u64;
14335 let mut coverage_lines_hit = 0u64;
14336 let mut coverage_functions_found = 0u64;
14337 let mut coverage_functions_hit = 0u64;
14338 let mut coverage_branches_found = 0u64;
14339 let mut coverage_branches_hit = 0u64;
14340 for r in &sub_files {
14341 functions += r.raw_line_categories.functions;
14342 classes += r.raw_line_categories.classes;
14343 variables += r.raw_line_categories.variables;
14344 imports += r.raw_line_categories.imports;
14345 test_count += r.raw_line_categories.test_count;
14346 test_assertion_count += r.raw_line_categories.test_assertion_count;
14347 test_suite_count += r.raw_line_categories.test_suite_count;
14348 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
14349 if let Some(cov) = &r.coverage {
14350 coverage_lines_found += u64::from(cov.lines_found);
14351 coverage_lines_hit += u64::from(cov.lines_hit);
14352 coverage_functions_found += u64::from(cov.functions_found);
14353 coverage_functions_hit += u64::from(cov.functions_hit);
14354 coverage_branches_found += u64::from(cov.branches_found);
14355 coverage_branches_hit += u64::from(cov.branches_hit);
14356 }
14357 }
14358
14359 AnalysisRun {
14360 tool: parent.tool.clone(),
14361 environment: parent.environment.clone(),
14362 effective_configuration: config,
14363 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
14364 summary_totals: SummaryTotals {
14365 files_considered: sub.files_analyzed,
14366 files_analyzed: sub.files_analyzed,
14367 files_skipped: 0,
14368 total_physical_lines: sub.total_physical_lines,
14369 code_lines: sub.code_lines,
14370 comment_lines: sub.comment_lines,
14371 blank_lines: sub.blank_lines,
14372 mixed_lines_separate,
14373 functions,
14374 classes,
14375 variables,
14376 imports,
14377 test_count,
14378 test_assertion_count,
14379 test_suite_count,
14380 coverage_lines_found,
14381 coverage_lines_hit,
14382 coverage_functions_found,
14383 coverage_functions_hit,
14384 coverage_branches_found,
14385 coverage_branches_hit,
14386 cyclomatic_complexity: 0,
14387 lsloc: None,
14388 },
14389 totals_by_language: sub.language_summaries.clone(),
14390 per_file_records: sub_files,
14391 skipped_file_records: vec![],
14392 warnings: vec![],
14393 submodule_summaries: vec![],
14394 git_commit_short: sub.git_commit_short.clone(),
14395 git_commit_long: sub.git_commit_long.clone(),
14396 git_branch: sub.git_branch.clone(),
14397 git_commit_author: sub.git_commit_author.clone(),
14398 git_commit_date: sub.git_commit_date.clone(),
14399 git_tags: None,
14400 git_nearest_tag: None,
14401 git_remote_url: sub.git_remote_url.clone(),
14402 style_summary: None,
14403 cocomo: None,
14404 uloc: 0,
14405 dryness_pct: None,
14406 duplicate_groups: vec![],
14407 duplicates_excluded: 0,
14408 }
14409}
14410
14411#[must_use]
14412pub fn sanitize_project_label(raw: &str) -> String {
14413 let candidate = raw
14416 .split(['/', '\\'])
14417 .rfind(|s| !s.is_empty())
14418 .unwrap_or("project");
14419
14420 let mut value = String::with_capacity(candidate.len());
14421 for ch in candidate.chars() {
14422 if ch.is_ascii_alphanumeric() {
14423 value.push(ch.to_ascii_lowercase());
14424 } else {
14425 value.push('-');
14426 }
14427 }
14428
14429 let compact = value.trim_matches('-').to_string();
14430 if compact.is_empty() {
14431 "project".to_string()
14432 } else {
14433 compact
14434 }
14435}
14436
14437fn strip_unc_prefix(path: PathBuf) -> PathBuf {
14440 let s = path.to_string_lossy();
14441 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14442 return PathBuf::from(format!(r"\\{rest}"));
14443 }
14444 if let Some(rest) = s.strip_prefix(r"\\?\") {
14445 return PathBuf::from(rest);
14446 }
14447 path
14448}
14449
14450fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14453 let base = if let Some(rest) = remote.strip_prefix("git@") {
14454 let (host, path) = rest.split_once(':')?;
14455 format!("https://{}/{}", host, path.trim_end_matches(".git"))
14456 } else if remote.starts_with("https://") || remote.starts_with("http://") {
14457 remote
14458 .trim_end_matches('/')
14459 .trim_end_matches(".git")
14460 .to_owned()
14461 } else {
14462 return None;
14463 };
14464 let base = base.trim_end_matches('/');
14465 if base.contains("gitlab.com") || base.contains("gitlab.") {
14467 Some(format!("{base}/-/commit/{sha}"))
14468 } else if base.contains("bitbucket.org") {
14469 Some(format!("{base}/commits/{sha}"))
14470 } else {
14471 Some(format!("{base}/commit/{sha}"))
14472 }
14473}
14474
14475fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
14478 let base = if let Some(rest) = remote.strip_prefix("git@") {
14479 let (host, path) = rest.split_once(':')?;
14480 format!("https://{}/{}", host, path.trim_end_matches(".git"))
14481 } else if remote.starts_with("https://") || remote.starts_with("http://") {
14482 remote
14483 .trim_end_matches('/')
14484 .trim_end_matches(".git")
14485 .to_owned()
14486 } else {
14487 return None;
14488 };
14489 let base = base.trim_end_matches('/');
14490 if base.contains("gitlab.com") || base.contains("gitlab.") {
14491 Some(format!("{base}/-/tree/{branch}"))
14492 } else {
14493 Some(format!("{base}/tree/{branch}"))
14494 }
14495}
14496
14497fn display_path(path: &Path) -> String {
14498 let s = path.to_string_lossy();
14499 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
14504 return format!(r"\\{rest}");
14505 }
14506 if let Some(rest) = s.strip_prefix(r"\\?\") {
14507 return rest.to_owned();
14508 }
14509 s.into_owned()
14510}
14511
14512fn sanitize_path_str(s: &str) -> String {
14513 if let Some(rest) = s.strip_prefix("//?/UNC/") {
14517 return format!("//{rest}");
14518 }
14519 if let Some(rest) = s.strip_prefix("//?/") {
14520 return rest.to_owned();
14521 }
14522 display_path(Path::new(s))
14523}
14524
14525fn workspace_root() -> PathBuf {
14526 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
14528 let p = PathBuf::from(root);
14529 if p.is_dir() {
14530 return p;
14531 }
14532 }
14533
14534 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
14537}
14538
14539fn make_git_label(repo: &str, ref_name: &str) -> String {
14541 if repo.is_empty() || ref_name.is_empty() {
14542 return String::new();
14543 }
14544 let base = repo
14545 .trim_end_matches('/')
14546 .trim_end_matches(".git")
14547 .rsplit('/')
14548 .next()
14549 .unwrap_or("repo");
14550 let ref_safe: String = ref_name
14551 .chars()
14552 .map(|c| {
14553 if c.is_alphanumeric() || c == '-' || c == '.' {
14554 c
14555 } else {
14556 '_'
14557 }
14558 })
14559 .collect();
14560 format!("{base}_at_{ref_safe}_sloc")
14561}
14562
14563fn desktop_dir() -> PathBuf {
14565 if let Ok(profile) = std::env::var("USERPROFILE") {
14566 let p = PathBuf::from(profile).join("Desktop");
14567 if p.exists() {
14568 return p;
14569 }
14570 }
14571 if let Ok(home) = std::env::var("HOME") {
14572 let p = PathBuf::from(home).join("Desktop");
14573 if p.exists() {
14574 return p;
14575 }
14576 }
14577 workspace_root().join("out").join("web")
14578}
14579
14580fn resolve_input_path(raw: &str) -> PathBuf {
14581 let trimmed = raw.trim();
14582 if trimmed.is_empty() {
14583 return workspace_root().join("samples").join("basic");
14584 }
14585
14586 let candidate = PathBuf::from(trimmed);
14587 let resolved = if candidate.is_absolute() {
14588 candidate
14589 } else {
14590 let rooted = workspace_root().join(&candidate);
14591 if rooted.exists() {
14592 rooted
14593 } else {
14594 workspace_root().join(candidate)
14595 }
14596 };
14597
14598 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
14601 PathBuf::from(display_path(&canonical))
14602}
14603
14604fn dir_size_bytes(path: &Path) -> u64 {
14605 let mut total = 0u64;
14606 if let Ok(rd) = fs::read_dir(path) {
14607 for entry in rd.filter_map(Result::ok) {
14608 let p = entry.path();
14609 if p.is_file() {
14610 if let Ok(meta) = p.metadata() {
14611 total += meta.len();
14612 }
14613 } else if p.is_dir() {
14614 total += dir_size_bytes(&p);
14615 }
14616 }
14617 }
14618 total
14619}
14620
14621#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
14623 if bytes >= 1_073_741_824 {
14624 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
14625 } else if bytes >= 1_048_576 {
14626 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
14627 } else if bytes >= 1_024 {
14628 format!("{:.0} KB", bytes as f64 / 1_024.0)
14629 } else {
14630 format!("{bytes} B")
14631 }
14632}
14633
14634fn render_submodule_chips(
14635 root: &Path,
14636 submodules: &[(String, std::path::PathBuf)],
14637 out: &mut String,
14638) {
14639 use std::fmt::Write as _;
14640 let count = submodules.len();
14641 out.push_str(r#"<div class="submodule-preview-strip">"#);
14642 write!(
14643 out,
14644 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>"#,
14645 if count == 1 { "" } else { "s" }
14646 )
14647 .ok();
14648 out.push_str(r#"<div class="submodule-preview-chips">"#);
14649 for (sub_name, sub_rel_path) in submodules {
14650 let sub_abs = root.join(sub_rel_path);
14651 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
14652 let mut sub_stats = PreviewStats::default();
14653 let mut sub_rows: Vec<PreviewRow> = Vec::new();
14654 let mut sub_langs: Vec<&'static str> = Vec::new();
14655 let mut sub_budget = PreviewBudget {
14656 shown: 0,
14657 max_entries: 2000,
14658 max_depth: 9,
14659 };
14660 let mut sub_next_id = 1usize;
14661 let _ = collect_preview_rows(
14662 &sub_abs,
14663 &sub_abs,
14664 0,
14665 None,
14666 &mut sub_next_id,
14667 &mut sub_budget,
14668 &mut sub_stats,
14669 &mut sub_rows,
14670 &mut sub_langs,
14671 &[],
14672 &[],
14673 );
14674 let stats_json = format!(
14675 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
14676 sub_stats.directories,
14677 sub_stats.files,
14678 sub_stats.supported,
14679 sub_stats.skipped,
14680 sub_stats.unsupported
14681 );
14682 write!(
14683 out,
14684 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>"#,
14685 escape_html(sub_name),
14686 escape_html(&sub_rel_path.to_string_lossy()),
14687 escape_html(&sub_size),
14688 escape_html(&stats_json),
14689 escape_html(sub_name),
14690 escape_html(&sub_size),
14691 )
14692 .ok();
14693 }
14694 out.push_str(
14695 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
14696 );
14697 out.push_str(r"</div>");
14698}
14699
14700fn render_language_pills_row(languages: &[&str], out: &mut String) {
14701 use std::fmt::Write as _;
14702 if languages.is_empty() {
14703 out.push_str(
14704 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
14705 );
14706 return;
14707 }
14708 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
14709 for language in languages {
14710 if let Some(icon) = language_icon_file(language) {
14711 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();
14712 } else if let Some(svg) = language_inline_svg(language) {
14713 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();
14714 } else {
14715 write!(
14716 out,
14717 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
14718 escape_html(&language.to_ascii_lowercase()),
14719 escape_html(language)
14720 )
14721 .ok();
14722 }
14723 }
14724}
14725
14726#[allow(clippy::too_many_lines)]
14727fn build_preview_html(
14728 root: &Path,
14729 include_patterns: &[String],
14730 exclude_patterns: &[String],
14731) -> Result<String> {
14732 if !root.exists() {
14733 return Ok(format!(
14734 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
14735 escape_html(&display_path(root))
14736 ));
14737 }
14738
14739 let _selected = display_path(root);
14740 let mut stats = PreviewStats::default();
14741 let mut rows = Vec::new();
14742 let mut languages = Vec::new();
14743 let mut budget = PreviewBudget {
14744 shown: 0,
14745 max_entries: 600,
14746 max_depth: 9,
14747 };
14748 let mut next_row_id = 1usize;
14749
14750 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
14751 || root.to_string_lossy().into_owned(),
14752 std::string::ToString::to_string,
14753 );
14754 let root_modified = root
14755 .metadata()
14756 .ok()
14757 .and_then(|meta| meta.modified().ok())
14758 .map_or_else(|| "-".to_string(), format_system_time);
14759
14760 rows.push(PreviewRow {
14761 row_id: 0,
14762 parent_row_id: None,
14763 depth: 0,
14764 name: format!("{root_name}/"),
14765 kind: PreviewKind::Dir,
14766 is_dir: true,
14767 language: None,
14768 modified: root_modified,
14769 type_label: "Directory".to_string(),
14770 });
14771 collect_preview_rows(
14772 root,
14773 root,
14774 0,
14775 Some(0),
14776 &mut next_row_id,
14777 &mut budget,
14778 &mut stats,
14779 &mut rows,
14780 &mut languages,
14781 include_patterns,
14782 exclude_patterns,
14783 )?;
14784
14785 let root_size = format_dir_size(dir_size_bytes(root));
14786
14787 let mut out = String::new();
14788 write!(
14789 out,
14790 r#"<div class="explorer-wrap" data-project-size="{}">"#,
14791 escape_html(&root_size)
14792 )
14793 .ok();
14794 out.push_str(r#"<div class="explorer-toolbar compact">"#);
14795 out.push_str(r#"<div class="explorer-title-group">"#);
14796 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
14797 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
14798 out.push_str(r"</div></div>");
14799
14800 out.push_str(r#"<div class="scope-stats">"#);
14801 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();
14802 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();
14803 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();
14804 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();
14805 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();
14806 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>"#);
14807 out.push_str(r"</div>");
14808
14809 let submodules = sloc_core::detect_submodules(root);
14810 if !submodules.is_empty() {
14811 render_submodule_chips(root, &submodules, &mut out);
14812 }
14813
14814 out.push_str(r#"<div class="scope-info-row">"#);
14815 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
14816 render_language_pills_row(&languages, &mut out);
14817 out.push_str(r"</div></div>");
14818 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>"#);
14819 out.push_str(r"</div>");
14820
14821 out.push_str(r#"<div class="file-explorer-shell">"#);
14822 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>"#);
14823 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>"#);
14824 out.push_str(r#"<div class="file-explorer-tree">"#);
14825 for row in rows {
14826 let status_label = row.kind.label();
14827 let lang_attr = row.language.unwrap_or("");
14828 let toggle_html = if row.is_dir {
14829 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
14830 .to_string()
14831 } else {
14832 r#"<span class="tree-bullet">•</span>"#.to_string()
14833 };
14834 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();
14835 }
14836 if budget.shown >= budget.max_entries {
14837 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>"#);
14838 }
14839 out.push_str(r"</div></div></div>");
14840
14841 Ok(out)
14842}
14843
14844#[derive(Default)]
14845struct PreviewStats {
14846 directories: usize,
14847 files: usize,
14848 supported: usize,
14849 skipped: usize,
14850 unsupported: usize,
14851}
14852
14853struct PreviewRow {
14854 row_id: usize,
14855 parent_row_id: Option<usize>,
14856 depth: usize,
14857 name: String,
14858 kind: PreviewKind,
14859 is_dir: bool,
14860 language: Option<&'static str>,
14861 modified: String,
14862 type_label: String,
14863}
14864
14865#[derive(Copy, Clone)]
14866enum PreviewKind {
14867 Dir,
14868 Supported,
14869 Skipped,
14870 Unsupported,
14871}
14872
14873impl PreviewKind {
14874 const fn filter_key(self) -> &'static str {
14875 match self {
14876 Self::Dir => "dir",
14877 Self::Supported => "supported",
14878 Self::Skipped => "skipped",
14879 Self::Unsupported => "unsupported",
14880 }
14881 }
14882
14883 const fn label(self) -> &'static str {
14884 match self {
14885 Self::Dir => "dir",
14886 Self::Supported => "supported",
14887 Self::Skipped => "skipped by policy",
14888 Self::Unsupported => "unsupported",
14889 }
14890 }
14891
14892 const fn badge_class(self) -> &'static str {
14893 match self {
14894 Self::Dir => "badge badge-dir",
14895 Self::Supported => "badge badge-scan",
14896 Self::Skipped => "badge badge-skip",
14897 Self::Unsupported => "badge badge-unsupported",
14898 }
14899 }
14900
14901 const fn node_class(self) -> &'static str {
14902 match self {
14903 Self::Dir => "tree-node-dir",
14904 Self::Supported => "tree-node-supported",
14905 Self::Skipped => "tree-node-skipped",
14906 Self::Unsupported => "tree-node-unsupported",
14907 }
14908 }
14909}
14910
14911struct PreviewBudget {
14912 shown: usize,
14913 max_entries: usize,
14914 max_depth: usize,
14915}
14916
14917#[allow(clippy::too_many_arguments)]
14920fn handle_preview_dir_entry(
14921 root: &Path,
14922 path: &Path,
14923 name: &str,
14924 modified: String,
14925 depth: usize,
14926 parent_row_id: Option<usize>,
14927 row_id: usize,
14928 next_row_id: &mut usize,
14929 budget: &mut PreviewBudget,
14930 stats: &mut PreviewStats,
14931 rows: &mut Vec<PreviewRow>,
14932 languages: &mut Vec<&'static str>,
14933 include_patterns: &[String],
14934 exclude_patterns: &[String],
14935) -> Result<()> {
14936 let relative = preview_relative_path(root, path);
14937 if should_skip_preview_directory(&relative, exclude_patterns) {
14938 return Ok(());
14939 }
14940 stats.directories += 1;
14941 rows.push(PreviewRow {
14942 row_id,
14943 parent_row_id,
14944 depth: depth + 1,
14945 name: format!("{name}/"),
14946 kind: PreviewKind::Dir,
14947 is_dir: true,
14948 language: None,
14949 modified,
14950 type_label: "Directory".to_string(),
14951 });
14952 budget.shown += 1;
14953 if !matches!(name, ".git" | "node_modules" | "target") {
14954 collect_preview_rows(
14955 root,
14956 path,
14957 depth + 1,
14958 Some(row_id),
14959 next_row_id,
14960 budget,
14961 stats,
14962 rows,
14963 languages,
14964 include_patterns,
14965 exclude_patterns,
14966 )?;
14967 }
14968 Ok(())
14969}
14970
14971#[allow(clippy::too_many_arguments)]
14973fn handle_preview_file_entry(
14974 root: &Path,
14975 path: &Path,
14976 name: &str,
14977 modified: String,
14978 depth: usize,
14979 parent_row_id: Option<usize>,
14980 row_id: usize,
14981 budget: &mut PreviewBudget,
14982 stats: &mut PreviewStats,
14983 rows: &mut Vec<PreviewRow>,
14984 languages: &mut Vec<&'static str>,
14985 include_patterns: &[String],
14986 exclude_patterns: &[String],
14987) {
14988 let relative = preview_relative_path(root, path);
14989 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
14990 return;
14991 }
14992 stats.files += 1;
14993 let kind = classify_preview_file(name);
14994 match kind {
14995 PreviewKind::Supported => stats.supported += 1,
14996 PreviewKind::Skipped => stats.skipped += 1,
14997 PreviewKind::Unsupported => stats.unsupported += 1,
14998 PreviewKind::Dir => {}
14999 }
15000 let language = detect_language_name(name);
15001 if let Some(lang) = language {
15002 if !languages.contains(&lang) {
15003 languages.push(lang);
15004 }
15005 }
15006 rows.push(PreviewRow {
15007 row_id,
15008 parent_row_id,
15009 depth: depth + 1,
15010 name: name.to_owned(),
15011 kind,
15012 is_dir: false,
15013 language,
15014 modified,
15015 type_label: preview_type_label(name, language, kind),
15016 });
15017 budget.shown += 1;
15018}
15019
15020#[allow(clippy::too_many_arguments)]
15021#[allow(clippy::too_many_lines)]
15022fn collect_preview_rows(
15023 root: &Path,
15024 dir: &Path,
15025 depth: usize,
15026 parent_row_id: Option<usize>,
15027 next_row_id: &mut usize,
15028 budget: &mut PreviewBudget,
15029 stats: &mut PreviewStats,
15030 rows: &mut Vec<PreviewRow>,
15031 languages: &mut Vec<&'static str>,
15032 include_patterns: &[String],
15033 exclude_patterns: &[String],
15034) -> Result<()> {
15035 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
15036 return Ok(());
15037 }
15038
15039 let mut entries = fs::read_dir(dir)
15040 .with_context(|| format!("failed to read directory {}", dir.display()))?
15041 .filter_map(std::result::Result::ok)
15042 .collect::<Vec<_>>();
15043 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
15044
15045 for entry in entries {
15046 if budget.shown >= budget.max_entries {
15047 break;
15048 }
15049
15050 let path = entry.path();
15051 let name = entry.file_name().to_string_lossy().into_owned();
15052 let Ok(metadata) = entry.metadata() else {
15053 continue;
15054 };
15055 let row_id = *next_row_id;
15056 *next_row_id += 1;
15057 let modified = metadata
15058 .modified()
15059 .ok()
15060 .map_or_else(|| "-".to_string(), format_system_time);
15061
15062 if metadata.is_dir() {
15063 handle_preview_dir_entry(
15064 root,
15065 &path,
15066 &name,
15067 modified,
15068 depth,
15069 parent_row_id,
15070 row_id,
15071 next_row_id,
15072 budget,
15073 stats,
15074 rows,
15075 languages,
15076 include_patterns,
15077 exclude_patterns,
15078 )?;
15079 continue;
15080 }
15081
15082 if metadata.is_file() {
15083 handle_preview_file_entry(
15084 root,
15085 &path,
15086 &name,
15087 modified,
15088 depth,
15089 parent_row_id,
15090 row_id,
15091 budget,
15092 stats,
15093 rows,
15094 languages,
15095 include_patterns,
15096 exclude_patterns,
15097 );
15098 }
15099 }
15100
15101 Ok(())
15102}
15103
15104fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
15105 if let Some(language) = language {
15106 return format!("{language} source");
15107 }
15108 let lower = name.to_ascii_lowercase();
15109 let ext = Path::new(&lower)
15110 .extension()
15111 .and_then(|e| e.to_str())
15112 .unwrap_or("");
15113 match kind {
15114 PreviewKind::Skipped => {
15115 if lower.ends_with(".min.js") {
15116 "Minified asset".to_string()
15117 } else if [
15118 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
15119 ]
15120 .contains(&ext)
15121 {
15122 "Binary or archive".to_string()
15123 } else {
15124 "Skipped file".to_string()
15125 }
15126 }
15127 PreviewKind::Unsupported => {
15128 if ext.is_empty() {
15129 "Unsupported file".to_string()
15130 } else {
15131 format!("{} file", ext.to_ascii_uppercase())
15132 }
15133 }
15134 PreviewKind::Supported => "Supported source".to_string(),
15135 PreviewKind::Dir => "Directory".to_string(),
15136 }
15137}
15138
15139fn format_system_time(time: SystemTime) -> String {
15140 #[allow(clippy::cast_possible_wrap)]
15141 let secs = match time.duration_since(UNIX_EPOCH) {
15142 Ok(duration) => duration.as_secs() as i64,
15143 Err(_) => return "-".to_string(),
15144 };
15145 let days = secs.div_euclid(86_400);
15146 let secs_of_day = secs.rem_euclid(86_400);
15147 let (year, month, day) = civil_from_days(days);
15148 let hour = secs_of_day / 3_600;
15149 let minute = (secs_of_day % 3_600) / 60;
15150 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
15151}
15152
15153#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
15154fn civil_from_days(days: i64) -> (i32, u32, u32) {
15155 let z = days + 719_468;
15156 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
15157 let doe = z - era * 146_097;
15158 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
15159 let y = yoe + era * 400;
15160 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
15161 let mp = (5 * doy + 2) / 153;
15162 let d = doy - (153 * mp + 2) / 5 + 1;
15163 let m = mp + if mp < 10 { 3 } else { -9 };
15164 let year = y + i64::from(m <= 2);
15165 (year as i32, m as u32, d as u32)
15166}
15167
15168#[allow(clippy::case_sensitive_file_extension_comparisons)]
15171fn detect_language_name(name: &str) -> Option<&'static str> {
15172 let lower = name.to_ascii_lowercase();
15173 if lower.ends_with(".c") || lower.ends_with(".h") {
15174 Some("C")
15175 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
15176 .iter()
15177 .any(|s| lower.ends_with(s))
15178 {
15179 Some("C++")
15180 } else if lower.ends_with(".cs") {
15181 Some("C#")
15182 } else if lower.ends_with(".py") {
15183 Some("Python")
15184 } else if lower.ends_with(".sh") {
15185 Some("Shell")
15186 } else if [".ps1", ".psm1", ".psd1"]
15187 .iter()
15188 .any(|s| lower.ends_with(s))
15189 {
15190 Some("PowerShell")
15191 } else {
15192 None
15193 }
15194}
15195
15196fn language_icon_file(language: &str) -> Option<&'static str> {
15197 match language {
15198 "C" => Some("c.png"),
15199 "C++" => Some("cpp.png"),
15200 "C#" => Some("c-sharp.png"),
15201 "Python" => Some("python.png"),
15202 "Shell" => Some("shell.png"),
15203 "PowerShell" => Some("powershell.png"),
15204 "JavaScript" => Some("java-script.png"),
15205 "HTML" => Some("html-5.png"),
15206 "Java" => Some("java.png"),
15207 "Visual Basic" => Some("visual-basic.png"),
15208 "Assembly" => Some("asm.png"),
15209 "Go" => Some("go.png"),
15210 "R" => Some("r.png"),
15211 "XML" => Some("xml.png"),
15212 "Groovy" => Some("groovy.png"),
15213 "Dockerfile" => Some("docker.png"),
15214 "Makefile" => Some("makefile.svg"),
15215 "Perl" => Some("perl.svg"),
15216 _ => None,
15217 }
15218}
15219
15220fn language_inline_svg(language: &str) -> Option<&'static str> {
15225 match language {
15226 "Rust" => Some(
15227 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>"##,
15228 ),
15229 "TypeScript" => Some(
15230 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>"##,
15231 ),
15232 _ => None,
15233 }
15234}
15235
15236#[allow(clippy::case_sensitive_file_extension_comparisons)]
15239fn classify_preview_file(name: &str) -> PreviewKind {
15240 let lower = name.to_ascii_lowercase();
15241
15242 let scannable = [
15243 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
15244 ".psm1", ".psd1",
15245 ]
15246 .iter()
15247 .any(|suffix| lower.ends_with(suffix));
15248
15249 if scannable {
15250 PreviewKind::Supported
15251 } else if lower.ends_with(".min.js")
15252 || lower.ends_with(".lock")
15253 || lower.ends_with(".png")
15254 || lower.ends_with(".jpg")
15255 || lower.ends_with(".jpeg")
15256 || lower.ends_with(".gif")
15257 || lower.ends_with(".zip")
15258 || lower.ends_with(".pdf")
15259 || lower.ends_with(".pyc")
15260 || lower.ends_with(".xz")
15261 || lower.ends_with(".tar")
15262 || lower.ends_with(".gz")
15263 {
15264 PreviewKind::Skipped
15265 } else {
15266 PreviewKind::Unsupported
15267 }
15268}
15269
15270fn preview_relative_path(root: &Path, path: &Path) -> String {
15271 path.strip_prefix(root)
15272 .ok()
15273 .unwrap_or(path)
15274 .to_string_lossy()
15275 .replace('\\', "/")
15276 .trim_matches('/')
15277 .to_string()
15278}
15279
15280fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
15281 if relative.is_empty() {
15282 return false;
15283 }
15284
15285 exclude_patterns.iter().any(|pattern| {
15286 wildcard_match(pattern, relative)
15287 || wildcard_match(pattern, &format!("{relative}/"))
15288 || wildcard_match(pattern, &format!("{relative}/placeholder"))
15289 })
15290}
15291
15292fn should_include_preview_file(
15293 relative: &str,
15294 include_patterns: &[String],
15295 exclude_patterns: &[String],
15296) -> bool {
15297 if relative.is_empty() {
15298 return true;
15299 }
15300
15301 let included = include_patterns.is_empty()
15302 || include_patterns
15303 .iter()
15304 .any(|pattern| wildcard_match(pattern, relative));
15305 let excluded = exclude_patterns
15306 .iter()
15307 .any(|pattern| wildcard_match(pattern, relative));
15308
15309 included && !excluded
15310}
15311
15312fn wildcard_match(pattern: &str, candidate: &str) -> bool {
15313 let pattern = pattern.trim().replace('\\', "/");
15314 let candidate = candidate.trim().replace('\\', "/");
15315 let p = pattern.as_bytes();
15316 let c = candidate.as_bytes();
15317 let mut pi = 0usize;
15318 let mut ci = 0usize;
15319 let mut star: Option<usize> = None;
15320 let mut star_match = 0usize;
15321
15322 while ci < c.len() {
15323 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
15324 pi += 1;
15325 ci += 1;
15326 } else if pi < p.len() && p[pi] == b'*' {
15327 while pi < p.len() && p[pi] == b'*' {
15328 pi += 1;
15329 }
15330 star = Some(pi);
15331 star_match = ci;
15332 } else if let Some(star_pi) = star {
15333 star_match += 1;
15334 ci = star_match;
15335 pi = star_pi;
15336 } else {
15337 return false;
15338 }
15339 }
15340
15341 while pi < p.len() && p[pi] == b'*' {
15342 pi += 1;
15343 }
15344
15345 pi == p.len()
15346}
15347
15348fn escape_html(value: &str) -> String {
15349 value
15350 .replace('&', "&")
15351 .replace('<', "<")
15352 .replace('>', ">")
15353 .replace('"', """)
15354 .replace('\'', "'")
15355}
15356
15357#[derive(Clone)]
15358struct SubmoduleRow {
15359 name: String,
15360 relative_path: String,
15361 files_analyzed: u64,
15362 code_lines: u64,
15363 comment_lines: u64,
15364 blank_lines: u64,
15365 total_physical_lines: u64,
15366 html_url: Option<String>,
15367}
15368
15369#[derive(Template)]
15370#[template(
15371 source = r##"
15372<!doctype html>
15373<html lang="en">
15374<head>
15375 <meta charset="utf-8">
15376 <title>OxideSLOC | tmp-sloc</title>
15377 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15378 <style nonce="{{ csp_nonce }}">
15379 :root {
15380 --bg: #efe9e2;
15381 --surface: #fcfaf7;
15382 --surface-2: #f7f0e8;
15383 --surface-3: #efe3d5;
15384 --line: #dfcfbf;
15385 --line-strong: #cfb29c;
15386 --text: #2f241c;
15387 --muted: #6f6257;
15388 --muted-2: #917f71;
15389 --nav: #b85d33;
15390 --nav-2: #7a371b;
15391 --accent: #2563eb;
15392 --accent-2: #1d4ed8;
15393 --oxide: #b85d33;
15394 --oxide-2: #8f4220;
15395 --success-bg: #eaf9ee;
15396 --success-text: #1c8746;
15397 --warn-bg: #fff2d8;
15398 --warn-text: #926000;
15399 --danger-bg: #fdeaea;
15400 --danger-text: #b33b3b;
15401 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
15402 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
15403 --radius: 14px;
15404 }
15405
15406 body.dark-theme {
15407 --bg: #1b1511;
15408 --surface: #261c17;
15409 --surface-2: #2d221d;
15410 --surface-3: #372922;
15411 --line: #524238;
15412 --line-strong: #6c5649;
15413 --text: #f5ece6;
15414 --muted: #c7b7aa;
15415 --muted-2: #aa9485;
15416 --nav: #b85d33;
15417 --nav-2: #7a371b;
15418 --accent: #6f9bff;
15419 --accent-2: #4a78ee;
15420 --oxide: #d37a4c;
15421 --oxide-2: #b35428;
15422 --success-bg: #163927;
15423 --success-text: #8fe2a8;
15424 --warn-bg: #3c2d11;
15425 --warn-text: #f3cb75;
15426 --danger-bg: #3d1f1f;
15427 --danger-text: #ff9f9f;
15428 --shadow: 0 14px 28px rgba(0,0,0,0.28);
15429 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
15430 }
15431
15432 * { box-sizing: border-box; }
15433 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); }
15434 html { overflow-y: scroll; }
15435 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
15436 .top-nav, .page, .loading { position: relative; z-index: 2; }
15437 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
15438 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
15439 .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); }
15440 .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; }
15441 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
15442 .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)); }
15443 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
15444 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
15445 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
15446 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
15447 .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; }
15448 .nav-project-pill.visible { display:inline-flex; }
15449 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
15450 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15451 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
15452 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15453 @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; } }
15454 .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; }
15455 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
15456 .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; }
15457 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
15458 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
15459 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
15460 .theme-toggle .icon-sun { display:none; }
15461 body.dark-theme .theme-toggle .icon-sun { display:block; }
15462 body.dark-theme .theme-toggle .icon-moon { display:none; }
15463 .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;}
15464 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15465 .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);}
15466 .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;}
15467 .settings-close:hover{color:var(--text);background:var(--surface-2);}
15468 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15469 .settings-modal-body{padding:14px 16px 16px;}
15470 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15471 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15472 .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;}
15473 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15474 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15475 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15476 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15477 .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;}
15478 .tz-select:focus{border-color:var(--oxide);}
15479 .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; }
15480 .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;}
15481 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
15482 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
15483 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
15484 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
15485 .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; }
15486 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
15487 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
15488 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
15489 .wb-stats-header { padding: 10px 24px 0; }
15490 .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
15491 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
15492 .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; }
15493 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15494 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15495 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15496 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
15497 .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; }
15498 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
15499 .ws-stat-analyzers { position: relative; }
15500 .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; }
15501 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
15502 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
15503 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
15504 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
15505 .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; }
15506 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
15507 .ws-divider { display: none; }
15508 .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%; }
15509 .ws-path-link:hover { color:var(--oxide); }
15510 body.dark-theme .ws-path-link { color:var(--oxide); }
15511 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
15512 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15513 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
15514 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
15515 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
15516 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
15517 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
15518 .ws-mini-box-lg { flex:2 1 0; }
15519 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
15520 .ws-mini-box-br { flex:1.5 1 0; }
15521 .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); }
15522 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
15523 .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
15524 #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; }
15525 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
15526 .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; }
15527 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
15528 .git-source-banner strong { font-weight:800; color:var(--text); }
15529 .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; }
15530 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
15531 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
15532 .git-source-banner a:hover { text-decoration:underline; }
15533 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
15534 .path-scope-sep { background:var(--line); margin:4px 14px; }
15535 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
15536 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
15537 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
15538 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
15539 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
15540 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
15541 .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; }
15542 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
15543 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
15544 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
15545 .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; }
15546 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
15547 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
15548 [data-wb-tip] { cursor:help; }
15549 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
15550 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
15551 .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; }
15552 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
15553 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
15554 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
15555 .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; }
15556 .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); }
15557 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
15558 .side-info-card { padding: 18px; }
15559 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
15560 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
15561 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
15562 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
15563 .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); }
15564 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
15565 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15566 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
15567 .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; }
15568 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
15569 .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; }
15570 .side-stack::-webkit-scrollbar { display: none; }
15571 .step-nav { padding: 20px 16px; }
15572 .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); }
15573 .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; }
15574 .step-button:hover { background: var(--surface-2); }
15575 .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); }
15576 .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; }
15577 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15578 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
15579 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
15580 .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); }
15581 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
15582 .step-nav-sum-row:last-child { border-bottom:none; }
15583 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
15584 .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; }
15585 .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
15586 .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
15587 .quick-scan-section { padding: 10px 4px 14px; }
15588 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
15589 .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; }
15590 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
15591 .quick-scan-btn:active { transform:translateY(0); }
15592 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
15593 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
15594 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
15595 @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);} }
15596 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
15597 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
15598 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
15599 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
15600 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
15601 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
15602 .step-button.done .step-check { opacity:1; }
15603 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
15604 .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; }
15605 .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; }
15606 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
15607 .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; }
15608 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
15609 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
15610 .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; }
15611 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
15612 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
15613 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
15614 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
15615 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
15616 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
15617 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
15618 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
15619 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
15620 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
15621 .card-body { padding: 22px; }
15622 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
15623 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
15624 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
15625 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
15626 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
15627 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
15628 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
15629 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
15630 .field { min-width:0; }
15631 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
15632 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; }
15633 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); }
15634 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
15635 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); }
15636 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
15637 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
15638 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
15639 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
15640 .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; }
15641 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
15642 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
15643 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
15644 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
15645 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
15646 .input-group.compact { grid-template-columns: 1fr auto auto; }
15647 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
15648 .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)); }
15649 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
15650 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
15651 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
15652 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
15653 .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; }
15654 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
15655 .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; }
15656 .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); }
15657 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
15658 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
15659 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
15660 button.secondary { background: var(--surface); }
15661 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15662 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
15663 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
15664 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
15665 .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); }
15666 .section + .wizard-actions { border-top: none; padding-top: 0; }
15667 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
15668 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15669 .field-help-grid.coupled-help { margin-top: 12px; }
15670 .field-help-grid.preset-grid { align-items: start; }
15671 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
15672 .preset-inline-row .field { margin: 0; }
15673 .preset-inline-row .explainer-card { margin: 0; }
15674 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
15675 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
15676 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
15677 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
15678 .preset-kv-row > :last-child { flex:1; min-width:0; }
15679 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
15680 .output-field-row .field { margin: 0; }
15681 .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; }
15682 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
15683 .step3-subtitle { margin-bottom: 10px; max-width: none; }
15684 .counting-intro { margin-bottom: 8px; max-width: none; }
15685 .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; }
15686 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
15687 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
15688 .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; }
15689 .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; }
15690 .section-spacer-top { margin-top: 28px; }
15691 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
15692 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
15693 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
15694 .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); }
15695 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
15696 .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; }
15697 .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; }
15698 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
15699 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15700 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
15701 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
15702 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
15703 .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; }
15704 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
15705 .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); }
15706 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; }
15707 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; }
15708 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
15709 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
15710 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
15711 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
15712 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
15713 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
15714 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
15715 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
15716 .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); }
15717 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
15718 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
15719 .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; }
15720 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
15721 .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; }
15722 .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; }
15723 .always-tracked-tip-body { flex:1; min-width:0; }
15724 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
15725 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
15726 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
15727 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
15728 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
15729 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
15730 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
15731 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
15732 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
15733 .advanced-rule-description strong { color: var(--text); }
15734 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
15735 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
15736 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
15737 .review-link:hover { text-decoration: underline; }
15738 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
15739 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
15740 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
15741 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
15742 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
15743 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
15744 .review-card ul { padding-left: 18px; margin: 0; }
15745 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
15746 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
15747 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
15748 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
15749 .review-card { min-height: 0; }
15750 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
15751 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
15752 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
15753 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
15754 .lang-overflow-chip { position:relative; cursor:default; }
15755 .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; }
15756 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
15757 .git-inline-row { align-items:start; }
15758 .mixed-line-card { display:flex; flex-direction:column; }
15759 .preset-inline-row .toggle-card { justify-content: center; }
15760 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
15761 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
15762 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
15763 .explorer-title { font-size: 18px; font-weight: 850; }
15764 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
15765 .explorer-subtitle.wide { max-width: none; }
15766 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
15767 .better-spacing { align-items:flex-start; justify-content:flex-end; }
15768 .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; }
15769 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
15770 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
15771 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
15772 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
15773 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
15774 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
15775 .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; }
15776 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
15777 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
15778 .scope-stat-button.supported { background: var(--success-bg); }
15779 .scope-stat-button.skipped { background: var(--warn-bg); }
15780 .scope-stat-button.unsupported { background: var(--danger-bg); }
15781 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
15782 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
15783 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
15784 [data-tooltip] { position: relative; }
15785 [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); }
15786 [data-tooltip]:hover::after { display: block; }
15787 .scope-stat-button[data-tooltip] { cursor: pointer; }
15788 .badge[data-tooltip] { cursor: help; }
15789 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
15790 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
15791 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
15792 .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; }
15793 .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; }
15794 code { display:inline-block; margin-top:0; padding:2px 7px; }
15795 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
15796 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
15797 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
15798 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
15799 .language-pill.muted-pill { color: var(--muted); }
15800 button.language-pill { appearance:none; cursor:pointer; }
15801 .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); }
15802 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
15803 .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; }
15804 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
15805 .file-explorer-search-row { margin-left: auto; }
15806 .explorer-filter-select { min-width: 170px; width: 170px; }
15807 .explorer-search { min-width: 300px; width: 300px; }
15808 .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); }
15809 .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; }
15810 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
15811 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
15812 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
15813 .file-explorer-tree { max-height: 640px; overflow:auto; }
15814 .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); }
15815 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
15816 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
15817 .tree-row.hidden-by-filter { display:none !important; }
15818 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
15819 .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; }
15820 .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; }
15821 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
15822 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
15823 .tree-node { display:inline-flex; align-items:center; min-width:0; }
15824 .tree-node-dir { color: var(--text); font-weight: 800; }
15825 .tree-node-supported { color: var(--success-text); }
15826 .tree-node-skipped { color: var(--warn-text); }
15827 .tree-node-unsupported { color: var(--danger-text); }
15828 .tree-node-more { color: var(--muted-2); font-style: italic; }
15829 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
15830 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
15831 .tree-status-cell { display:flex; justify-content:flex-start; }
15832 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
15833 .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; }
15834 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
15835 .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; }
15836 @keyframes prevSpin { to { transform:rotate(360deg); } }
15837 .preview-loading-text { flex:1; min-width:0; }
15838 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
15839 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
15840 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
15841 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
15842 .cov-scan-idle { display:none; }
15843 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
15844 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
15845 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
15846 .cov-scan-title { font-weight:600; font-size:12.5px; }
15847 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
15848 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
15849 .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; }
15850 .cov-scan-use:hover { opacity:.75; }
15851 .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; }
15852 .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; }
15853 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
15854 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
15855 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
15856 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
15857 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
15858 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
15859 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
15860 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
15861 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
15862 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
15863 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
15864 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
15865 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
15866 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
15867 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
15868 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
15869 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
15870 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
15871 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
15872 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
15873 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
15874 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
15875 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
15876 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
15877 .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); }
15878 .loading.active { display:flex; }
15879 .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; }
15880 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
15881 .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; }
15882 @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
15883 .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); }
15884 .lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
15885 .lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
15886 .lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
15887 @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
15888 @keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
15889 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
15890 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
15891 .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; }
15892 .lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
15893 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
15894 .lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
15895 .lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
15896 .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; }
15897 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
15898 .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; }
15899 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
15900 .lc-step.done { color:var(--muted);opacity:0.55; }
15901 .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; }
15902 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
15903 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
15904 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
15905 .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; }
15906 .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; }
15907 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
15908 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
15909 .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; }
15910 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
15911 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
15912 .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; }
15913 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
15914 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
15915 .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; }
15916 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
15917 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
15918 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
15919 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
15920 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
15921 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
15922 .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; }
15923 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
15924 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
15925 .hidden { display:none !important; }
15926 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15927 .site-footer a{color:var(--muted);}
15928 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
15929 @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; } }
15930 .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;}
15931 @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));}}
15932 .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;}
15933 .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; }
15934 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
15935 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
15936 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
15937 .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; }
15938 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
15939 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
15940 .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; }
15941 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
15942 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
15943 .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; }
15944 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
15945 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
15946 .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; }
15947 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
15948 .info-icon-btn:hover { color:var(--text); }
15949 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); }
15950 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
15951 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
15952 .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;}
15953 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15954 .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;}
15955 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15956 #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);}
15957 #offline-file-banner.show{display:flex;}
15958 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
15959 #offline-file-banner .ofb-text{flex:1;}
15960 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
15961 #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;}
15962 #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;}
15963 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
15964 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
15965 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
15966 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
15967 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
15968 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
15969 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
15970 </style>
15971</head>
15972<body id="page-top">
15973 <div id="offline-file-banner" role="alert">
15974 <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>
15975 <span class="ofb-text">
15976 Charts, images, and navigation require the oxide-sloc server.
15977 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
15978 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
15979 The metric tables below are fully readable without the server.
15980 </span>
15981 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
15982 </div>
15983 <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>
15984 <div class="background-watermarks" aria-hidden="true">
15985 <img src="/images/logo/logo-text.png" alt="" />
15986 <img src="/images/logo/logo-text.png" alt="" />
15987 <img src="/images/logo/logo-text.png" alt="" />
15988 <img src="/images/logo/logo-text.png" alt="" />
15989 <img src="/images/logo/logo-text.png" alt="" />
15990 <img src="/images/logo/logo-text.png" alt="" />
15991 <img src="/images/logo/logo-text.png" alt="" />
15992 <img src="/images/logo/logo-text.png" alt="" />
15993 <img src="/images/logo/logo-text.png" alt="" />
15994 <img src="/images/logo/logo-text.png" alt="" />
15995 <img src="/images/logo/logo-text.png" alt="" />
15996 <img src="/images/logo/logo-text.png" alt="" />
15997 <img src="/images/logo/logo-text.png" alt="" />
15998 <img src="/images/logo/logo-text.png" alt="" />
15999 </div>
16000 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16001 <div class="top-nav">
16002 <div class="top-nav-inner">
16003 <a class="brand" href="/">
16004 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
16005 <div class="brand-copy">
16006 <div class="brand-title">OxideSLOC</div>
16007 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
16008 </div>
16009 </a>
16010 <div class="nav-project-slot">
16011 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
16012 <span class="nav-project-label">Project</span>
16013 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
16014 </div>
16015 </div>
16016 <div class="nav-status">
16017 <a class="nav-pill" href="/">Home</a>
16018 <div class="nav-dropdown">
16019 <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>
16020 <div class="nav-dropdown-menu">
16021 <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>
16022 </div>
16023 </div>
16024 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16025 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16026 <div class="nav-dropdown">
16027 <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>
16028 <div class="nav-dropdown-menu">
16029 <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>
16030 </div>
16031 </div>
16032 <div class="server-status-wrap" id="server-status-wrap">
16033 <div class="nav-pill server-online-pill" id="server-status-pill">
16034 <span class="status-dot" id="status-dot"></span>
16035 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
16036 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
16037 </div>
16038 <div class="server-status-tip">
16039 {% if server_mode %}
16040 OxideSLOC is running in server mode — accessible on your LAN.
16041 {% else %}
16042 OxideSLOC is running locally — only accessible from this machine.
16043 {% endif %}
16044 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
16045 </div>
16046 </div>
16047 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16048 <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>
16049 </button>
16050 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
16051 <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>
16052 <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>
16053 </button>
16054 </div>
16055 </div>
16056 </div>
16057
16058 <div class="loading" id="loading">
16059 <div class="loading-card">
16060 <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>
16061 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
16062 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
16063 <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>
16064 <div class="lc-steps" id="lc-steps">
16065 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
16066 <div class="lc-step-arrow">›</div>
16067 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
16068 <div class="lc-step-arrow">›</div>
16069 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
16070 <div class="lc-step-arrow">›</div>
16071 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
16072 </div>
16073 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
16074 <div class="lc-metrics" id="lc-metrics">
16075 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
16076 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
16077 <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>
16078 <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>
16079 </div>
16080 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
16081 <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>
16082 <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>
16083 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
16084 <div class="lc-actions hidden" id="lc-actions">
16085 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
16086 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
16087 </div>
16088 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
16089 <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>
16090 Cancel scan
16091 </button>
16092 </div>
16093 </div>
16094
16095 <div class="page">
16096 <div class="workbench-strip">
16097 <div class="workbench-box wb-stats">
16098 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
16099 <span class="wb-stats-title">Analysis session</span>
16100 </div>
16101 <div class="ws-left">
16102 <div class="ws-stat ws-stat-analyzers">
16103 <span class="ws-label">Analyzers</span>
16104 <span class="ws-value">
16105 <span class="ws-badge">60 languages</span>
16106 </span>
16107 <div class="ws-lang-tooltip">
16108 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
16109 <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>
16110 <div class="ws-lang-grid">
16111 <span class="ws-lang-item">Assembly</span>
16112 <span class="ws-lang-item">C</span>
16113 <span class="ws-lang-item">C++</span>
16114 <span class="ws-lang-item">C#</span>
16115 <span class="ws-lang-item">Clojure</span>
16116 <span class="ws-lang-item">CSS</span>
16117 <span class="ws-lang-item">Dart</span>
16118 <span class="ws-lang-item">Dockerfile</span>
16119 <span class="ws-lang-item">Elixir</span>
16120 <span class="ws-lang-item">Erlang</span>
16121 <span class="ws-lang-item">F#</span>
16122 <span class="ws-lang-item">Go</span>
16123 <span class="ws-lang-item">Groovy</span>
16124 <span class="ws-lang-item">Haskell</span>
16125 <span class="ws-lang-item">HTML</span>
16126 <span class="ws-lang-item">Java</span>
16127 <span class="ws-lang-item">JavaScript</span>
16128 <span class="ws-lang-item">Julia</span>
16129 <span class="ws-lang-item">Kotlin</span>
16130 <span class="ws-lang-item">Lua</span>
16131 <span class="ws-lang-item">Makefile</span>
16132 <span class="ws-lang-item">Nim</span>
16133 <span class="ws-lang-item">Obj-C</span>
16134 <span class="ws-lang-item">OCaml</span>
16135 <span class="ws-lang-item">Perl</span>
16136 <span class="ws-lang-item">PHP</span>
16137 <span class="ws-lang-item">PowerShell</span>
16138 <span class="ws-lang-item">Python</span>
16139 <span class="ws-lang-item">R</span>
16140 <span class="ws-lang-item">Ruby</span>
16141 <span class="ws-lang-item">Rust</span>
16142 <span class="ws-lang-item">Scala</span>
16143 <span class="ws-lang-item">SCSS</span>
16144 <span class="ws-lang-item">Shell</span>
16145 <span class="ws-lang-item">SQL</span>
16146 <span class="ws-lang-item">Svelte</span>
16147 <span class="ws-lang-item">Swift</span>
16148 <span class="ws-lang-item">TypeScript</span>
16149 <span class="ws-lang-item">Vue</span>
16150 <span class="ws-lang-item">XML</span>
16151 <span class="ws-lang-item">Zig</span>
16152 <span class="ws-lang-item">Solidity</span>
16153 <span class="ws-lang-item">Protobuf</span>
16154 <span class="ws-lang-item">HCL</span>
16155 <span class="ws-lang-item">GraphQL</span>
16156 <span class="ws-lang-item">Ada</span>
16157 <span class="ws-lang-item">VHDL</span>
16158 <span class="ws-lang-item">Verilog</span>
16159 <span class="ws-lang-item">Tcl</span>
16160 <span class="ws-lang-item">Pascal</span>
16161 <span class="ws-lang-item">Visual Basic</span>
16162 <span class="ws-lang-item">Lisp</span>
16163 <span class="ws-lang-item">Fortran</span>
16164 <span class="ws-lang-item">Nix</span>
16165 <span class="ws-lang-item">Crystal</span>
16166 <span class="ws-lang-item">D</span>
16167 <span class="ws-lang-item">GLSL</span>
16168 <span class="ws-lang-item">CMake</span>
16169 <span class="ws-lang-item">Elm</span>
16170 <span class="ws-lang-item">Awk</span>
16171 </div>
16172 </div>
16173 </div>
16174 <div class="ws-divider"></div>
16175 <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>
16176 <div class="ws-divider"></div>
16177 <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.">
16178 <span class="ws-label">Output</span>
16179 <span class="ws-value">
16180 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
16181 <span id="ws-output-root">project/sloc</span>
16182 </button>
16183 </span>
16184 </div>
16185 </div>
16186 </div>
16187 <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.">
16188 <div class="ws-history-label">Scan history</div>
16189 <div class="ws-history-inner">
16190 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
16191 <div class="ws-mini-label">Scans</div>
16192 <div class="ws-mini-value" id="ws-scan-count">—</div>
16193 </div>
16194 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
16195 <div class="ws-mini-label">Last Scan</div>
16196 <div class="ws-mini-value" id="ws-last-scan">—</div>
16197 </div>
16198 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
16199 <div class="ws-mini-label">Branch</div>
16200 <div class="ws-mini-value" id="ws-branch">—</div>
16201 </div>
16202 </div>
16203 </div>
16204 </div>
16205
16206 <div class="layout">
16207 <aside class="side-stack">
16208 <section class="step-nav">
16209 <h3>Guided scan setup</h3>
16210 <div class="sidebar-scroll-divider"></div>
16211 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
16212 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
16213 Top of page
16214 </a>
16215 <div class="sidebar-scroll-divider"></div>
16216 <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>
16217 <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>
16218 <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>
16219 <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>
16220
16221 <div class="step-steps-divider"></div>
16222
16223 <div class="step-nav-info" id="step-nav-info">
16224 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
16225 <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>
16226 </div>
16227
16228 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
16229 <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>
16230 <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>
16231 <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>
16232 </div>
16233
16234 <div class="quick-scan-divider"></div>
16235 <div class="quick-scan-section">
16236 <div class="quick-scan-label">No customization needed?</div>
16237 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
16238 <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>
16239 Quick Scan
16240 </button>
16241 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
16242 </div>
16243
16244 <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>
16245 <div class="sidebar-scroll-divider"></div>
16246 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
16247 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
16248 Skip to bottom
16249 </a>
16250 </section>
16251
16252 </aside>
16253
16254 <section class="card">
16255 <div class="card-header">
16256 <div class="card-title-row">
16257 <div>
16258 <h1 class="card-title">Guided scan configuration</h1>
16259 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
16260 </div>
16261 <div class="wizard-progress" aria-label="Scan setup progress">
16262 <div class="wizard-progress-top">
16263 <span class="wizard-progress-label">Setup progress</span>
16264 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
16265 </div>
16266 <div class="wizard-progress-track">
16267 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
16268 </div>
16269 </div>
16270 </div>
16271 </div>
16272 <div class="card-body">
16273 <form method="post" action="/analyze" id="analyze-form">
16274 <div class="wizard-step active" data-step="1">
16275 <div class="section">
16276 <div class="section-kicker">Step 1</div>
16277 <h2>Select project and preview scope</h2>
16278 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
16279 <div class="field">
16280 <label for="path">Project path</label>
16281 {% if !git_repo.is_empty() %}
16282 <div class="git-source-banner">
16283 <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>
16284 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
16285 <a href="/git-browser">← Back to Git Browser</a>
16286 </div>
16287 {% endif %}
16288 <div class="path-scope-grid">
16289 {% if !git_repo.is_empty() %}
16290 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
16291 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
16292 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
16293 {% else %}
16294 <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
16295 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
16296 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
16297 {% endif %}
16298 <div class="path-scope-sep"></div>
16299 <div class="scope-legend-row">
16300 <span class="scope-legend-label">Scope legend:</span>
16301 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
16302 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
16303 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
16304 </div>
16305 </div>
16306 {% if git_repo.is_empty() %}
16307 {% if server_mode %}
16308 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
16309 ℹ️ Files are compressed and streamed — no fixed size limit.
16310 </div>
16311 {% endif %}
16312 <div class="path-info-row">
16313 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
16314 <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>
16315 <span id="project-size-text">Project size: —</span>
16316 </button>
16317 </div>
16318 {% else %}
16319 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
16320 {% endif %}
16321 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
16322 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
16323 </div>
16324
16325 <div class="scope-preview-divider" aria-hidden="true"></div>
16326
16327 <div id="preview-panel">
16328 <div class="preview-error">Loading preview...</div>
16329 </div>
16330 </div>
16331
16332 <div class="section" style="margin-top:14px;">
16333 <div class="preset-inline-row git-inline-row">
16334 <div class="toggle-card" style="margin:0;">
16335 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
16336 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
16337 <label class="checkbox">
16338 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
16339 <div>
16340 <span>Detect and separate git submodules</span>
16341 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
16342 </div>
16343 </label>
16344 </div>
16345 <div class="explainer-card prominent" style="margin:0;">
16346 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16347 <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>
16348 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
16349 path = libs/core
16350 url = https://github.com/org/core.git
16351
16352[submodule "libs/ui"]
16353 path = libs/ui
16354 url = https://github.com/org/ui.git</div>
16355 </div>
16356 </div>
16357 </div>
16358
16359 <div class="section">
16360 <div class="field-grid">
16361 <div class="field">
16362 <div class="glob-label-row">
16363 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
16364 <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>
16365 </div>
16366 <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>
16367 <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>
16368 </div>
16369 <div class="field">
16370 <div class="glob-label-row">
16371 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
16372 </div>
16373 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
16374 <div id="quick-exclude-chips" class="quick-excl-row">
16375 <span class="quick-excl-label">Quick add:</span>
16376 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
16377 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
16378 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
16379 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
16380 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
16381 <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>
16382 </div>
16383 <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>
16384 </div>
16385 </div>
16386 <div class="glob-guidance-grid">
16387 <div class="glob-guidance-card">
16388 <strong>How to read them</strong>
16389 <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>
16390 </div>
16391 <div class="glob-guidance-card">
16392 <strong>Common include examples</strong>
16393 <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>
16394 </div>
16395 <div class="glob-guidance-card">
16396 <strong>Common exclude examples</strong>
16397 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
16398 </div>
16399 </div>
16400 </div>
16401
16402 <div class="section" style="margin-top:14px;">
16403 <div class="preset-inline-row git-inline-row">
16404 <div class="toggle-card" style="margin:0;">
16405 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
16406 <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>
16407 <div class="field" style="margin:0;">
16408 <div class="input-group compact">
16409 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
16410 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
16411 </div>
16412 <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>
16413 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
16414 </div>
16415 </div>
16416 <div class="explainer-card prominent" style="margin:0;">
16417 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
16418 <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>
16419 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
16420lcov --capture --directory . --output-file coverage/lcov.info
16421
16422# C / C++ — llvm-cov (LCOV)
16423llvm-profdata merge -sparse default.profraw -o default.profdata
16424llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
16425
16426# C# — coverlet (Cobertura XML)
16427dotnet test --collect:"XPlat Code Coverage"
16428
16429# Python — pytest-cov (Cobertura XML)
16430pytest --cov --cov-report=xml
16431
16432# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
16433./gradlew jacocoTestReport</div>
16434 </div>
16435 </div>
16436 </div>
16437
16438 <div class="wizard-actions">
16439 <div class="left"></div>
16440 <div class="right">
16441 <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
16442 </div>
16443 </div>
16444 </div>
16445
16446 <div class="wizard-step" data-step="2">
16447 <div class="section">
16448 <div class="section-kicker">Step 2</div>
16449 <h2>Choose counting behavior</h2>
16450 <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>
16451<div class="subsection-bar">Primary line classification</div>
16452 <div class="preset-kv-row">
16453 <div class="toggle-card mixed-line-card" style="margin:0;">
16454 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
16455 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
16456 <select id="mixed_line_policy" name="mixed_line_policy">
16457 <option value="code_only">Code only</option>
16458 <option value="code_and_comment">Code and comment</option>
16459 <option value="comment_only">Comment only</option>
16460 <option value="separate_mixed_category">Separate mixed category</option>
16461 </select>
16462 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
16463 </div>
16464 <div class="explainer-card prominent" style="margin:0;">
16465 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
16466 <div class="explainer-body" id="mixed-policy-description"></div>
16467 <div class="code-sample" id="mixed-policy-example"></div>
16468 </div>
16469 </div>
16470 </div>
16471
16472 <div class="subsection-bar">Additional scan rules</div>
16473 <div class="scan-rules-grid">
16474 <div class="preset-inline-row">
16475 <div class="toggle-card" style="margin:0;">
16476 <div class="field-help-title">Generated files</div>
16477 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
16478 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16479 </div>
16480 <div class="explainer-card prominent" style="margin:0;">
16481 <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>
16482 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
16483# Files matching codegen patterns are excluded:
16484# *.generated.cs *.pb.go *.g.dart</div>
16485 </div>
16486 </div>
16487 <div class="preset-inline-row">
16488 <div class="toggle-card" style="margin:0;">
16489 <div class="field-help-title">Minified files</div>
16490 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
16491 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16492 </div>
16493 <div class="explainer-card prominent" style="margin:0;">
16494 <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>
16495 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
16496# Heuristic: very long lines + low whitespace ratio
16497# jquery.min.js bundle.min.css → skipped</div>
16498 </div>
16499 </div>
16500 <div class="preset-inline-row">
16501 <div class="toggle-card" style="margin:0;">
16502 <div class="field-help-title">Vendor directories</div>
16503 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
16504 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
16505 </div>
16506 <div class="explainer-card prominent" style="margin:0;">
16507 <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>
16508 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
16509# Directories named vendor/ node_modules/ third_party/
16510# → entire subtree is excluded from totals</div>
16511 </div>
16512 </div>
16513 <div class="preset-inline-row">
16514 <div class="toggle-card" style="margin:0;">
16515 <div class="field-help-title">Lockfiles and manifests</div>
16516 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
16517 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
16518 </div>
16519 <div class="explainer-card prominent" style="margin:0;">
16520 <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>
16521 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
16522# Files like package-lock.json Cargo.lock yarn.lock
16523# → skipped unless this is enabled</div>
16524 </div>
16525 </div>
16526 <div class="preset-inline-row">
16527 <div class="toggle-card" style="margin:0;">
16528 <div class="field-help-title">Binary handling</div>
16529 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
16530 <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>
16531 </div>
16532 <div class="explainer-card prominent" style="margin:0;">
16533 <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>
16534 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
16535# Detected via long lines + low whitespace heuristic
16536# .png .exe .so → skipped silently</div>
16537 </div>
16538 </div>
16539 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
16540 <div class="toggle-card" style="margin:0;">
16541 <div class="field-help-title">Python docstrings</div>
16542 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
16543 <label class="checkbox">
16544 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
16545 <span>Count as comment-style lines</span>
16546 </label>
16547 </div>
16548 <div class="explainer-card prominent" style="margin:0;">
16549 <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>
16550 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
16551 </div>
16552 </div>
16553 </div>
16554 <div class="subsection-bar">IEEE 1045-1992 counting</div>
16555 <div class="scan-rules-grid">
16556 <div class="preset-inline-row">
16557 <div class="toggle-card" style="margin:0;">
16558 <div class="field-help-title">Continuation lines</div>
16559 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
16560 <select name="continuation_line_policy" id="continuation_line_policy">
16561 <option value="each_physical_line" selected>Each physical line (default)</option>
16562 <option value="collapse_to_logical">Collapse to logical line</option>
16563 </select>
16564 </div>
16565 <div class="explainer-card prominent" style="margin:0;">
16566 <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>
16567 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
16568 ((a) > (b) ? (a) : (b))
16569# each_physical_line → 2 SLOC
16570# collapse_to_logical → 1 SLOC</div>
16571 </div>
16572 </div>
16573 <div class="preset-inline-row">
16574 <div class="toggle-card" style="margin:0;">
16575 <div class="field-help-title">Block-comment blanks</div>
16576 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
16577 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
16578 <option value="count_as_comment" selected>Count as comment (default)</option>
16579 <option value="count_as_blank">Count as blank</option>
16580 </select>
16581 </div>
16582 <div class="explainer-card prominent" style="margin:0;">
16583 <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>
16584 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
16585 * Summary line
16586 * ← blank inside block comment
16587 * Detail line
16588 */
16589# count_as_comment → blank counts toward comments
16590# count_as_blank → blank counts toward blanks</div>
16591 </div>
16592 </div>
16593 <div class="preset-inline-row">
16594 <div class="toggle-card" style="margin:0;">
16595 <div class="field-help-title">Compiler directives</div>
16596 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
16597 <select name="count_compiler_directives" id="count_compiler_directives">
16598 <option value="enabled" selected>Include in code SLOC (default)</option>
16599 <option value="disabled">Exclude from code SLOC</option>
16600 </select>
16601 </div>
16602 <div class="explainer-card prominent" style="margin:0;">
16603 <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>
16604 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
16605#define BUF 256 ← compiler directive
16606int main() { … } ← code
16607# enabled → 3 code SLOC
16608# disabled → 1 code SLOC + 2 directive lines</div>
16609 </div>
16610 </div>
16611 </div>
16612
16613 <div class="subsection-bar">Code Style Analysis</div>
16614 <div class="scan-rules-grid">
16615 <div class="preset-inline-row">
16616 <div class="toggle-card" style="margin:0;">
16617 <div class="field-help-title">Style analysis</div>
16618 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
16619 <select name="style_analysis_enabled" id="style_analysis_enabled">
16620 <option value="enabled" selected>Enabled (default)</option>
16621 <option value="disabled">Disabled — skip style scoring</option>
16622 </select>
16623 </div>
16624 <div class="explainer-card prominent" style="margin:0;">
16625 <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>
16626 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
16627# style_analysis_enabled = false (skip, faster scan)
16628# Disabling removes the Code Style section from the report.</div>
16629 </div>
16630 </div>
16631 <div class="preset-inline-row">
16632 <div class="toggle-card" style="margin:0;">
16633 <div class="field-help-title">Column-width threshold</div>
16634 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
16635 <select name="style_col_threshold" id="style_col_threshold">
16636 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
16637 <option value="100">100 columns (Uber Go, Google Java)</option>
16638 <option value="120">120 columns (Uber Go max, Kotlin)</option>
16639 </select>
16640 </div>
16641 <div class="explainer-card prominent" style="margin:0;">
16642 <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>
16643 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
16644# style_col_threshold = 100 (Uber Go, Google Java)
16645# style_col_threshold = 120 (Uber Go max, Kotlin)
16646# Files where <= 5% of lines exceed the limit
16647# are counted as "N-col compliant" in the report.</div>
16648 </div>
16649 </div>
16650 <div class="preset-inline-row">
16651 <div class="toggle-card" style="margin:0;">
16652 <div class="field-help-title">Score alert threshold</div>
16653 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
16654 <select name="style_score_threshold" id="style_score_threshold">
16655 <option value="0" selected>Off — no threshold (default)</option>
16656 <option value="40">40% — flag poorly styled files</option>
16657 <option value="50">50% — flag below-average files</option>
16658 <option value="60">60% — flag below-good files</option>
16659 <option value="70">70% — flag below-strong files</option>
16660 </select>
16661 </div>
16662 <div class="explainer-card prominent" style="margin:0;">
16663 <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>
16664 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
16665# style_score_threshold = 50 (flag files < 50%)
16666# Low-scoring files get a red left-border in the
16667# per-file style breakdown table.</div>
16668 </div>
16669 </div>
16670 </div>
16671
16672 <div class="always-tracked-tip">
16673 <div class="always-tracked-tip-icon">ℹ</div>
16674 <div class="always-tracked-tip-body">
16675 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
16676 <h4>Comment and blank-line basics & Lines on the boundary</h4>
16677 <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>
16678 </div>
16679 </div>
16680
16681 <div class="subsection-bar">Advanced Metrics</div>
16682 <div class="scan-rules-grid">
16683 <div class="preset-inline-row">
16684 <div class="toggle-card" style="margin:0;">
16685 <div class="field-help-title">COCOMO mode</div>
16686 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
16687 <select name="cocomo_mode" id="cocomo_mode">
16688 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
16689 <option value="semi_detached">Semi-detached — mixed constraints</option>
16690 <option value="embedded">Embedded — tight hardware/OS constraints</option>
16691 </select>
16692 </div>
16693 <div class="explainer-card prominent" style="margin:0;">
16694 <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>
16695 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
16696# Semi-detached: Effort = 3.0 × KSLOC^1.12
16697# Embedded: Effort = 3.6 × KSLOC^1.20
16698# All modes: Schedule = 2.5 × Effort^d</div>
16699 </div>
16700 </div>
16701 <div class="preset-inline-row">
16702 <div class="toggle-card" style="margin:0;">
16703 <div class="field-help-title">Complexity alert</div>
16704 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
16705 <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;" />
16706 </div>
16707 <div class="explainer-card prominent" style="margin:0;">
16708 <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>
16709 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
16710# 50 = flag any file with > 50 branch points
16711# 100 = flag any file with > 100 branch points
16712# Files above the threshold are highlighted
16713# in the result page metric strip.</div>
16714 </div>
16715 </div>
16716 <div class="preset-inline-row">
16717 <div class="toggle-card" style="margin:0;">
16718 <div class="field-help-title">Duplicate handling</div>
16719 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
16720 <select name="exclude_duplicates" id="exclude_duplicates">
16721 <option value="disabled" selected>Detect and report only (default)</option>
16722 <option value="enabled">Detect and exclude from SLOC totals</option>
16723 </select>
16724 </div>
16725 <div class="explainer-card prominent" style="margin:0;">
16726 <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>
16727 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
16728# detect only → all 3 counted in SLOC
16729# exclude dupes → 1 counted, 2 excluded
16730# Duplicate groups chip always shows the count.</div>
16731 </div>
16732 </div>
16733 <div class="always-tracked-tip" style="margin:8px 0 0;">
16734 <div class="always-tracked-tip-icon">ℹ</div>
16735 <div class="always-tracked-tip-body">
16736 <div class="field-help-title">Always computed — every scan produces these automatically</div>
16737 <div class="always-tracked-metrics-row">
16738 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
16739 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
16740 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
16741 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
16742 </div>
16743 <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>
16744 </div>
16745 </div>
16746 </div>
16747
16748 <div class="wizard-actions">
16749 <div class="left">
16750 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
16751 </div>
16752 <div class="right">
16753 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
16754 </div>
16755 </div>
16756 </div>
16757
16758 <div class="wizard-step" data-step="3">
16759 <div class="section">
16760 <div class="section-kicker">Step 3</div>
16761 <h2>Output and report identity</h2>
16762 <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>
16763 <div class="preset-kv-row">
16764 <div class="toggle-card" style="margin:0;">
16765 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
16766 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
16767 <select id="scan_preset">
16768 <option value="balanced">Balanced local scan</option>
16769 <option value="code_focused">Code focused</option>
16770 <option value="comment_audit">Comment audit</option>
16771 <option value="deep_review">Deep review</option>
16772 </select>
16773 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
16774 </div>
16775 <div class="explainer-card">
16776 <div class="field-help-title">Selected scan preset</div>
16777 <div class="explainer-body" id="scan-preset-description"></div>
16778 <div class="preset-summary-row" id="scan-preset-summary"></div>
16779 <div class="code-sample" id="scan-preset-example"></div>
16780 <div class="preset-note" id="scan-preset-note"></div>
16781 </div>
16782 </div>
16783 <hr class="step3-separator" />
16784 <div class="preset-kv-row">
16785 <div class="toggle-card" style="margin:0;">
16786 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
16787 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
16788 <select id="artifact_preset">
16789 <option value="review">Review bundle</option>
16790 <option value="full">Full bundle</option>
16791 <option value="html_only">HTML only</option>
16792 <option value="machine">Machine bundle</option>
16793 </select>
16794 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
16795 </div>
16796 <div class="explainer-card">
16797 <div class="field-help-title">Selected artifact preset</div>
16798 <div class="explainer-body" id="artifact-preset-description"></div>
16799 <div class="preset-summary-row" id="artifact-preset-summary"></div>
16800 <div class="code-sample" id="artifact-preset-example"></div>
16801 </div>
16802 </div>
16803 </div>
16804
16805 <div class="section section-spacer-top">
16806 <div class="output-field-row">
16807 <div class="field">
16808 <label for="output_dir">Output directory</label>
16809 {% if server_mode %}
16810 <div class="input-group compact">
16811 <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);" />
16812 </div>
16813 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
16814 {% else %}
16815 <div class="input-group compact">
16816 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
16817 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
16818 <button type="button" class="mini-button" id="use-default-output">Use default</button>
16819 </div>
16820 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
16821 {% endif %}
16822 </div>
16823 <div class="output-field-aside">
16824 <strong>Where reports land</strong>
16825 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.
16826 </div>
16827 </div>
16828 </div>
16829
16830 <div class="section section-spacer-top">
16831 <div class="output-field-row">
16832 <div class="field">
16833 <label for="report_title">Report title</label>
16834 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
16835 <div class="hint">Appears in HTML and PDF output headers.</div>
16836 </div>
16837 <div class="output-field-aside">
16838 <strong>Shown in exported artifacts</strong>
16839 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.
16840 </div>
16841 </div>
16842 </div>
16843
16844 <div class="section section-spacer-top">
16845 <div class="output-field-row">
16846 <div class="field">
16847 <label for="report_header_footer">Report header / footer</label>
16848 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
16849 <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>
16850 </div>
16851 <div class="output-field-aside">
16852 <strong>Page-level identification</strong>
16853 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.
16854 </div>
16855 </div>
16856 </div>
16857
16858 <div class="wizard-actions">
16859 <div class="left">
16860 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
16861 </div>
16862 <div class="right">
16863 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
16864 </div>
16865 </div>
16866 </div>
16867
16868 <div class="wizard-step" data-step="4">
16869 <div class="section">
16870 <div class="section-kicker">Step 4</div>
16871 <h2>Review selections and run</h2>
16872 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
16873 <div class="review-grid">
16874 <div class="review-card highlight">
16875 <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>
16876 <ul id="review-scan-summary"></ul>
16877 </div>
16878 <div class="review-card highlight">
16879 <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>
16880 <ul id="review-count-summary"></ul>
16881 </div>
16882 <div class="review-card">
16883 <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>
16884 <ul id="review-artifact-summary"></ul>
16885 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
16886 </div>
16887 <div class="review-card">
16888 <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>
16889 <ul id="review-preview-summary"></ul>
16890 </div>
16891 </div>
16892 </div>
16893
16894 <div class="wizard-actions">
16895 <div class="left">
16896 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
16897 </div>
16898 <div class="right">
16899 <button type="submit" id="submit-button" class="primary">Run analysis</button>
16900 </div>
16901 </div>
16902 </div>
16903 {% if server_mode %}
16904 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
16905 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
16906 {% endif %}
16907 </form>
16908 </div>
16909 </section>
16910 </div>
16911 </div>
16912
16913 <script nonce="{{ csp_nonce }}">
16914 (function () {
16915 function startScanPhase() {
16916 var phaseEl = document.getElementById("scan-phase");
16917 if (!phaseEl) return;
16918 var phases = [
16919 "Discovering files...",
16920 "Decoding file encodings...",
16921 "Detecting languages...",
16922 "Analyzing source lines...",
16923 "Applying counting policies...",
16924 "Aggregating results...",
16925 "Rendering report..."
16926 ];
16927 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
16928 var i = 0;
16929 function next() {
16930 phaseEl.style.opacity = "0";
16931 setTimeout(function () {
16932 phaseEl.textContent = phases[i];
16933 phaseEl.style.opacity = "0.85";
16934 var delay = durations[i] || 1800;
16935 i++;
16936 if (i < phases.length) { setTimeout(next, delay); }
16937 }, 200);
16938 }
16939 next();
16940 }
16941
16942 var form = document.getElementById("analyze-form");
16943 var loading = document.getElementById("loading");
16944 var submitButton = document.getElementById("submit-button");
16945 var pathInput = document.getElementById("path");
16946 var GIT_MODE = !!(pathInput && pathInput.readOnly);
16947 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
16948 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
16949 var outputDirInput = document.getElementById("output_dir");
16950 var reportTitleInput = document.getElementById("report_title");
16951 var previewPanel = document.getElementById("preview-panel");
16952 var refreshButton = document.getElementById("refresh-preview");
16953 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
16954 var useSamplePath = document.getElementById("use-sample-path");
16955 var useDefaultOutput = document.getElementById("use-default-output");
16956 var browsePath = document.getElementById("browse-path");
16957 var browseOutputDir = document.getElementById("browse-output-dir");
16958 var browseCoverage = document.getElementById("browse-coverage");
16959 var coverageInput = document.getElementById("coverage_file");
16960 var covScanStatus = document.getElementById("cov-scan-status");
16961 var coverageSuggestTimer = null;
16962 var covAutoFilled = false;
16963 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
16964
16965 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
16966 (function() {
16967 var ids = ["path", "output_dir"];
16968 ids.forEach(function(id) {
16969 var el = document.getElementById(id);
16970 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
16971 });
16972 }());
16973 function fmtBytes(b) {
16974 b = Number(b) || 0;
16975 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
16976 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
16977 if (b >= 1024) return Math.round(b / 1024) + ' KB';
16978 return b + ' B';
16979 }
16980 var themeToggle = document.getElementById("theme-toggle");
16981
16982 function showBannerToast(msg, isError, opts) {
16983 opts = opts || {};
16984 var t = document.createElement('div');
16985 t.className = isError ? 'toast-error' : 'toast-success';
16986 var topPos = opts.top ? '80px' : null;
16987 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
16988 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
16989 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
16990 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
16991 if (opts.icon) {
16992 var inner = document.createElement('span');
16993 inner.innerHTML = opts.icon + ' ';
16994 t.appendChild(inner);
16995 }
16996 t.appendChild(document.createTextNode(msg));
16997 document.body.appendChild(t);
16998 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
16999 }
17000 var mixedLinePolicy = document.getElementById("mixed_line_policy");
17001 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
17002 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
17003 var scanPreset = document.getElementById("scan_preset");
17004 var artifactPreset = document.getElementById("artifact_preset");
17005 var includeGlobsInput = document.getElementById("include_globs");
17006 var excludeGlobsInput = document.getElementById("exclude_globs");
17007
17008 // Include globs scope badge — updates reactively as the user types.
17009 (function() {
17010 var badge = document.getElementById("include-scope-badge");
17011 if (!badge || !includeGlobsInput) return;
17012 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> ';
17013 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> ';
17014 function update() {
17015 var val = includeGlobsInput.value.trim();
17016 if (!val) {
17017 badge.className = "include-scope-badge scope-all";
17018 badge.innerHTML = iconCheck + "All files eligible — no include filter active";
17019 } else {
17020 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
17021 badge.className = "include-scope-badge scope-narrow";
17022 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " — only matching files will be included";
17023 }
17024 }
17025 includeGlobsInput.addEventListener("input", update);
17026 update();
17027 }());
17028
17029 // Quick-exclude chips — append pattern to exclude_globs textarea.
17030 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
17031 chip.addEventListener("click", function() {
17032 var pattern = chip.getAttribute("data-pattern") || "";
17033 if (!pattern || !excludeGlobsInput) return;
17034 var current = excludeGlobsInput.value.trim();
17035 // For the "skip all" chip, replace any existing dep patterns cleanly.
17036 var patterns = pattern.split("\n");
17037 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
17038 var added = false;
17039 patterns.forEach(function(p) {
17040 p = p.trim();
17041 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
17042 });
17043 if (added) {
17044 excludeGlobsInput.value = lines.join("\n");
17045 excludeGlobsInput.dispatchEvent(new Event("input"));
17046 }
17047 chip.classList.add("active");
17048 });
17049 });
17050
17051 var liveReportTitle = document.getElementById("live-report-title");
17052 var navProjectPill = document.getElementById("nav-project-pill");
17053 var navProjectTitle = document.getElementById("nav-project-title");
17054 var reportTitlePreview = null;
17055 var wizardProgressFill = document.getElementById("wizard-progress-fill");
17056 var wizardProgressValue = document.getElementById("wizard-progress-value");
17057 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
17058 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
17059 var reportTitleTouched = false;
17060 var currentStep = 1;
17061 var previewTimer = null;
17062 var _previewGen = 0;
17063 var quickScanBtn = document.getElementById("quick-scan-btn");
17064
17065 function dismissAnalysisModal() {
17066 if (loading) loading.classList.remove("active");
17067 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17068 var el = document.getElementById(id);
17069 if (el) el.classList.add("hidden");
17070 });
17071 var cancelBtn = document.getElementById("lc-cancel-btn");
17072 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
17073 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
17074 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
17075 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
17076 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");}
17077 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
17078 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17079 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17080 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17081 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17082 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17083 }
17084
17085 var lcDismissBtn = document.getElementById("lc-dismiss");
17086 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
17087
17088 // When the browser restores this page from bfcache (Back button after navigating to results),
17089 // the loading overlay would still be showing its active state. Dismiss it immediately.
17090 window.addEventListener("pageshow", function(e) {
17091 if (e.persisted) { dismissAnalysisModal(); }
17092 });
17093
17094 function startAsyncAnalysis(formData) {
17095 var gitRepo = (formData.get("git_repo") || "").toString();
17096 var gitRef = (formData.get("git_ref") || "").toString();
17097 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
17098 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
17099
17100 var pathEl = document.getElementById("lc-path-text");
17101 if (pathEl) pathEl.textContent = displayPath;
17102
17103 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
17104 var el = document.getElementById(id);
17105 if (el) el.classList.add("hidden");
17106 });
17107 var cancelBtn = document.getElementById("lc-cancel-btn");
17108 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
17109 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
17110 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
17111 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
17112 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
17113 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
17114 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
17115 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");}
17116 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
17117
17118 if (loading) loading.classList.add("active");
17119
17120 var startTime = Date.now();
17121 var elapsedTimer = setInterval(function() {
17122 var s = Math.floor((Date.now() - startTime) / 1000);
17123 var el = document.getElementById("lc-elapsed");
17124 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
17125 }, 1000);
17126
17127 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
17128
17129 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();}
17130
17131 var PHASE_DESC = {
17132 'Starting': 'Initializing language analyzers and loading configuration…',
17133 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
17134 'Running': 'Running the lexical state machine across all discovered source files…',
17135 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
17136 'Done': 'Analysis complete — loading your results…',
17137 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
17138 };
17139 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
17140 function lcSetPhase(txt) {
17141 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
17142 var desc = document.getElementById("lc-stage-desc");
17143 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
17144 var step = PHASE_STEP[txt] || 1;
17145 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");}
17146 }
17147
17148 function lcShowCancelled() {
17149 clearInterval(elapsedTimer);
17150 var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
17151 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
17152 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
17153 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
17154 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
17155 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
17156 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
17157 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
17158 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17159 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17160 }
17161
17162 var lcCancelBtn = document.getElementById("lc-cancel-btn");
17163 if (lcCancelBtn) {
17164 lcCancelBtn.onclick = function() {
17165 if (!activeWaitId) { dismissAnalysisModal(); return; }
17166 lcCancelBtn.disabled = true;
17167 lcCancelBtn.textContent = "Cancelling…";
17168 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
17169 .then(function() { lcShowCancelled(); })
17170 .catch(function() { lcShowCancelled(); });
17171 };
17172 }
17173
17174 function lcShowError(msg) {
17175 clearInterval(elapsedTimer);
17176 lcSetPhase("Failed");
17177 var msgEl = document.getElementById("lc-err-msg");
17178 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
17179 var errEl = document.getElementById("lc-err");
17180 var actEl = document.getElementById("lc-actions");
17181 if (errEl) errEl.classList.remove("hidden");
17182 if (actEl) actEl.classList.remove("hidden");
17183 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
17184 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
17185 }
17186
17187 function lcPoll(waitId) {
17188 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
17189 .then(function(r) {
17190 if (!r.ok) throw new Error("HTTP " + r.status);
17191 return r.json();
17192 })
17193 .then(function(data) {
17194 pollRetries = 0;
17195 if (data.state === "complete") {
17196 clearInterval(elapsedTimer);
17197 lcSetPhase("Done");
17198 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
17199 } else if (data.state === "failed") {
17200 lcShowError(data.message);
17201 } else if (data.state === "cancelled") {
17202 lcShowCancelled();
17203 } else {
17204 var s = Math.floor((Date.now() - startTime) / 1000);
17205 if (s > 90 && !warnShown) {
17206 warnShown = true;
17207 var w = document.getElementById("lc-warn");
17208 if (w) w.classList.remove("hidden");
17209 }
17210 lcSetPhase(data.phase || "Running");
17211 var fd = data.files_done || 0, ft = data.files_total || 0;
17212 if (ft > 0) {
17213 var card = document.getElementById("lc-files-card");
17214 if (card) card.classList.remove("hidden");
17215 var el = document.getElementById("lc-files");
17216 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
17217 var now = Date.now();
17218 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
17219 if (fdelta > 0 && tdelta > 0.4) {
17220 var fps = Math.round(fdelta / tdelta);
17221 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
17222 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
17223 }
17224 lastFd = fd; lastFdTime = now;
17225 }
17226 setTimeout(function() { lcPoll(waitId); }, 1500);
17227 }
17228 })
17229 .catch(function() {
17230 pollRetries++;
17231 if (pollRetries >= 5) {
17232 lcShowError("Lost connection to server. Reload to check status.");
17233 } else {
17234 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
17235 }
17236 });
17237 }
17238
17239 var params = new URLSearchParams(formData);
17240 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
17241 .then(function(r) {
17242 var waitId = r.headers.get("x-wait-id");
17243 if (!waitId) { window.location.href = "/scan"; return; }
17244 activeWaitId = waitId;
17245 setTimeout(function() { lcPoll(waitId); }, 1500);
17246 })
17247 .catch(function(err) {
17248 lcShowError("Could not reach server: " + (err.message || err));
17249 });
17250 }
17251
17252 if (quickScanBtn) {
17253 quickScanBtn.addEventListener("click", function () {
17254 var pathVal = pathInput ? pathInput.value.trim() : "";
17255 if (!pathVal) {
17256 alert("Please enter or browse to a project path first.");
17257 return;
17258 }
17259 quickScanBtn.disabled = true;
17260 quickScanBtn.textContent = "Scanning...";
17261 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
17262 startAsyncAnalysis(new FormData(form));
17263 });
17264 }
17265
17266 var mixedPolicyInfo = {
17267 code_only: {
17268 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.",
17269 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'
17270 },
17271 code_and_comment: {
17272 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.",
17273 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'
17274 },
17275 comment_only: {
17276 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.",
17277 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'
17278 },
17279 separate_mixed_category: {
17280 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.",
17281 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'
17282 }
17283 };
17284
17285 var scanPresetInfo = {
17286 balanced: {
17287 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.",
17288 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
17289 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
17290 note: "Best when you want a stable local overview before making deeper adjustments.",
17291 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17292 },
17293 code_focused: {
17294 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
17295 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
17296 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
17297 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
17298 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17299 },
17300 comment_audit: {
17301 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
17302 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
17303 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
17304 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
17305 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
17306 },
17307 deep_review: {
17308 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
17309 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
17310 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
17311 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
17312 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
17313 }
17314 };
17315
17316 var artifactPresetInfo = {
17317 review: {
17318 description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
17319 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
17320 example: "Ideal for a quick local review before sharing results."
17321 },
17322 full: {
17323 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
17324 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
17325 example: "Use when producing a deliverable or storing a snapshot for future comparison."
17326 },
17327 html_only: {
17328 description: "Standalone HTML report only. No PDF generation, no data files.",
17329 chips: ["HTML only"],
17330 example: "Fastest option when you only need to open the report in a browser."
17331 },
17332 machine: {
17333 description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
17334 chips: ["JSON", "CSV", "no HTML", "no PDF"],
17335 example: "Use in CI to capture metrics without generating visual reports."
17336 }
17337 };
17338
17339 function applyArtifactPreset() {
17340 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
17341 if (!info) return;
17342 var descEl = document.getElementById("artifact-preset-description");
17343 var exampleEl = document.getElementById("artifact-preset-example");
17344 if (descEl) descEl.textContent = info.description;
17345 if (exampleEl) exampleEl.textContent = info.example;
17346 renderPresetChips("artifact-preset-summary", info.chips);
17347 }
17348
17349 function applyTheme(theme) {
17350 if (theme === "dark") document.body.classList.add("dark-theme");
17351 else document.body.classList.remove("dark-theme");
17352 }
17353
17354 function loadSavedTheme() {
17355 var saved = null;
17356 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
17357 applyTheme(saved === "dark" ? "dark" : "light");
17358 }
17359
17360 function updateScrollProgress() {
17361 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
17362 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
17363 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
17364 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
17365 var step = Math.min(Math.max(currentStep, 1), 4);
17366 var base = stepBase[step];
17367 var end = stepEnd[step];
17368
17369 var scrollFrac = 0;
17370 var activePanel = document.querySelector(".wizard-step.active");
17371 if (activePanel) {
17372 var scrollTop = window.scrollY || window.pageYOffset || 0;
17373 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
17374 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
17375 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
17376 var scrolled = scrollTop + viewH - panelTop;
17377 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
17378 }
17379
17380 var percent = Math.round(base + (end - base) * scrollFrac);
17381 percent = Math.min(end, Math.max(base, percent));
17382 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
17383 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
17384 }
17385
17386 function updateWizardProgress() {
17387 updateScrollProgress();
17388 }
17389
17390 var stepDescriptions = [
17391 "Choose a project folder, apply scope filters, and preview which files will be counted.",
17392 "Configure how mixed code-plus-comment lines and docstrings are classified.",
17393 "Pick your output formats, scan preset, and where reports are saved.",
17394 "Review all settings and launch the analysis."
17395 ];
17396
17397 function updateStepNav(step) {
17398 var infoLabel = document.getElementById("step-nav-info-label");
17399 var infoDesc = document.getElementById("step-nav-info-desc");
17400 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
17401 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
17402 }
17403
17404 function updateSidebarSummary() {
17405 var sumPath = document.getElementById("sum-path");
17406 var sumPreset = document.getElementById("sum-preset");
17407 var sumOutput = document.getElementById("sum-output");
17408 var sidebarSummary = document.getElementById("sidebar-summary");
17409 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
17410 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
17411 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
17412 if (sumPath) sumPath.textContent = pathVal || "—";
17413 if (sumPreset) sumPreset.textContent = presetVal || "—";
17414 if (sumOutput) sumOutput.textContent = outputVal || "—";
17415 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
17416 }
17417
17418 function setStep(step, pushHistory) {
17419 currentStep = step;
17420 stepPanels.forEach(function (panel) {
17421 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
17422 });
17423 stepButtons.forEach(function (button) {
17424 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
17425 });
17426 var layoutEl = document.querySelector(".layout");
17427 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
17428 updateWizardProgress();
17429 updateStepNav(step);
17430 stepButtons.forEach(function(btn) {
17431 var t = Number(btn.getAttribute("data-step-target"));
17432 btn.classList.toggle("done", t < step);
17433 });
17434 updateSidebarSummary();
17435
17436 if (pushHistory !== false) {
17437 try {
17438 history.pushState({ wizardStep: step }, "", "#step" + step);
17439 } catch (e) {}
17440 }
17441
17442 window.scrollTo({ top: 0, behavior: "instant" });
17443 }
17444
17445 window.addEventListener("popstate", function (e) {
17446 if (e.state && e.state.wizardStep) {
17447 setStep(e.state.wizardStep, false);
17448 } else {
17449 var hashMatch = location.hash.match(/^#step([1-4])$/);
17450 if (hashMatch) setStep(Number(hashMatch[1]), false);
17451 }
17452 });
17453
17454 function inferTitleFromPath(value) {
17455 if (!value) return "project";
17456 var cleaned = value.replace(/[\/\\]+$/, "");
17457 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
17458 return parts.length ? parts[parts.length - 1] : value;
17459 }
17460
17461 function updateReportTitleFromPath() {
17462 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
17463 if (!reportTitleTouched) {
17464 reportTitleInput.value = inferred;
17465 }
17466 var title = reportTitleInput.value || inferred;
17467 if (liveReportTitle) liveReportTitle.textContent = title;
17468 if (reportTitlePreview) reportTitlePreview.textContent = title;
17469 document.title = "OxideSLOC | " + title;
17470
17471 var projectPath = (pathInput.value || "").trim();
17472 if (navProjectPill && navProjectTitle) {
17473 if (projectPath.length > 0) {
17474 navProjectTitle.textContent = inferred;
17475 navProjectPill.classList.add("visible");
17476 } else {
17477 navProjectTitle.textContent = "";
17478 navProjectPill.classList.remove("visible");
17479 }
17480 }
17481 }
17482
17483 function updateMixedPolicyUI() {
17484 var key = mixedLinePolicy.value || "code_only";
17485 var info = mixedPolicyInfo[key];
17486 document.getElementById("mixed-policy-description").textContent = info.description;
17487 document.getElementById("mixed-policy-example").textContent = info.example;
17488 }
17489
17490 function updatePythonDocstringUI() {
17491 var checked = !!pythonDocstrings.checked;
17492 document.getElementById("python-docstring-example").textContent = checked
17493 ? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
17494 : 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
17495 document.getElementById("python-docstring-live-help").textContent = checked
17496 ? "Enabled: docstrings contribute to comment-style totals."
17497 : "Disabled: docstrings are not counted as comment content.";
17498 }
17499
17500 function renderPresetChips(targetId, chips) {
17501 var target = document.getElementById(targetId);
17502 if (!target) return;
17503 target.innerHTML = (chips || []).map(function (chip) {
17504 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
17505 }).join('');
17506 }
17507
17508 function updatePresetDescriptions() {
17509 var scanInfo = scanPresetInfo[scanPreset.value];
17510 if (!scanInfo) return;
17511 document.getElementById("scan-preset-description").textContent = scanInfo.description;
17512 document.getElementById("scan-preset-example").textContent = scanInfo.example;
17513 document.getElementById("scan-preset-note").textContent = scanInfo.note;
17514 renderPresetChips("scan-preset-summary", scanInfo.chips);
17515 }
17516
17517 function applyScanPreset() {
17518 var info = scanPresetInfo[scanPreset.value];
17519 if (!info || !info.apply) return;
17520 mixedLinePolicy.value = info.apply.mixed;
17521 pythonDocstrings.checked = !!info.apply.docstrings;
17522 document.getElementById("generated_file_detection").value = info.apply.generated;
17523 document.getElementById("minified_file_detection").value = info.apply.minified;
17524 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
17525 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
17526 document.getElementById("binary_file_behavior").value = info.apply.binary;
17527 updateMixedPolicyUI();
17528 updatePythonDocstringUI();
17529 }
17530
17531 function updateReview() {
17532 var scanSummary = document.getElementById("review-scan-summary");
17533 var countSummary = document.getElementById("review-count-summary");
17534 var artifactSummary = document.getElementById("review-artifact-summary");
17535 var outputSummary = document.getElementById("review-output-summary");
17536 var previewSummary = document.getElementById("review-preview-summary");
17537 var readinessSummary = document.getElementById("review-readiness-summary");
17538 var includeText = document.getElementById("include_globs").value.trim();
17539 var excludeText = document.getElementById("exclude_globs").value.trim();
17540 var sidePathPreview = document.getElementById("side-path-preview");
17541 var sideOutputPreview = document.getElementById("side-output-preview");
17542 var sideTitlePreview = document.getElementById("side-title-preview");
17543
17544 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
17545 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
17546 if (sideTitlePreview) {
17547 var rt = document.getElementById("report_title");
17548 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
17549 }
17550
17551 scanSummary.innerHTML = ""
17552 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
17553 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
17554 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
17555
17556 countSummary.innerHTML = ""
17557 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
17558 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
17559 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
17560 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
17561 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
17562 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
17563 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
17564 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
17565
17566 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
17567
17568 outputSummary.innerHTML = ""
17569 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
17570 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
17571
17572 if (previewSummary) {
17573 if (GIT_MODE) {
17574 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>';
17575 } else {
17576 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
17577 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
17578 var statMap = {};
17579 statButtons.forEach(function (button) {
17580 var valueNode = button.querySelector('.scope-stat-value');
17581 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
17582 });
17583 previewSummary.innerHTML = ''
17584 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
17585 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
17586 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
17587 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
17588 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
17589 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
17590
17591 if (readinessSummary) {
17592 readinessSummary.innerHTML = ''
17593 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
17594 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
17595 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
17596 }
17597 } // end else (non-GIT_MODE)
17598 }
17599 }
17600
17601 function escapeHtml(value) {
17602 return String(value)
17603 .replace(/&/g, "&")
17604 .replace(/</g, "<")
17605 .replace(/>/g, ">")
17606 .replace(/"/g, """)
17607 .replace(/'/g, "'");
17608 }
17609
17610 function isPythonVisible() {
17611 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
17612 }
17613
17614 function syncPythonVisibility() {
17615 var html = previewPanel.textContent || "";
17616 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
17617 pythonWraps.forEach(function (node) {
17618 node.classList.toggle("hidden", !hasPython);
17619 });
17620 }
17621
17622 function attachPreviewInteractions() {
17623 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
17624 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
17625 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
17626 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
17627 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
17628 var searchInput = previewPanel.querySelector("#explorer-search");
17629 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
17630 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
17631 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
17632 var activeFilter = "all";
17633 var activeLanguage = "";
17634 var searchTerm = "";
17635 var currentSortKey = null;
17636 var currentSortOrder = "asc";
17637 var childRows = {};
17638
17639 rows.forEach(function (row) {
17640 var parentId = row.getAttribute("data-parent-id") || "";
17641 var rowId = row.getAttribute("data-row-id") || "";
17642 if (!childRows[parentId]) childRows[parentId] = [];
17643 childRows[parentId].push(rowId);
17644 });
17645
17646 function rowById(id) {
17647 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
17648 }
17649
17650 function hasCollapsedAncestor(row) {
17651 var parentId = row.getAttribute("data-parent-id");
17652 while (parentId) {
17653 var parent = rowById(parentId);
17654 if (!parent) break;
17655 if (parent.getAttribute("data-expanded") === "false") return true;
17656 parentId = parent.getAttribute("data-parent-id");
17657 }
17658 return false;
17659 }
17660
17661 function updateToggleGlyph(row) {
17662 var toggle = row.querySelector(".tree-toggle");
17663 if (!toggle) return;
17664 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
17665 }
17666
17667 function rowSortValue(row, key) {
17668 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
17669 }
17670
17671 function updateSortButtons() {
17672 sortButtons.forEach(function (button) {
17673 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
17674 var indicator = button.querySelector(".tree-sort-indicator");
17675 button.classList.toggle("active", isActive);
17676 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
17677 if (indicator) {
17678 indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
17679 }
17680 });
17681 }
17682
17683 function sortSiblingRows() {
17684 if (!treeContainer) {
17685 updateSortButtons();
17686 return;
17687 }
17688
17689 var rowMap = {};
17690 var childrenMap = {};
17691 rows.forEach(function (row) {
17692 var rowId = row.getAttribute("data-row-id");
17693 var parentId = row.getAttribute("data-parent-id") || "";
17694 rowMap[rowId] = row;
17695 if (!childrenMap[parentId]) childrenMap[parentId] = [];
17696 childrenMap[parentId].push(rowId);
17697 });
17698
17699 Object.keys(childrenMap).forEach(function (parentId) {
17700 if (!parentId) return;
17701 childrenMap[parentId].sort(function (a, b) {
17702 var rowA = rowMap[a];
17703 var rowB = rowMap[b];
17704 if (!currentSortKey) {
17705 return Number(a) - Number(b);
17706 }
17707 var valueA = rowSortValue(rowA, currentSortKey);
17708 var valueB = rowSortValue(rowB, currentSortKey);
17709 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
17710 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
17711 var fallbackA = rowSortValue(rowA, "name");
17712 var fallbackB = rowSortValue(rowB, "name");
17713 if (fallbackA < fallbackB) return -1;
17714 if (fallbackA > fallbackB) return 1;
17715 return Number(a) - Number(b);
17716 });
17717 });
17718
17719 var orderedIds = [];
17720 function pushChildren(parentId) {
17721 (childrenMap[parentId] || []).forEach(function (childId) {
17722 orderedIds.push(childId);
17723 pushChildren(childId);
17724 });
17725 }
17726
17727 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
17728 orderedIds.push(topId);
17729 pushChildren(topId);
17730 });
17731
17732 orderedIds.forEach(function (id) {
17733 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
17734 });
17735 updateSortButtons();
17736 }
17737
17738 function updateLanguageButtons() {
17739 languageButtons.forEach(function (button) {
17740 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
17741 var isActive = languageValue === activeLanguage;
17742 button.classList.toggle("active", isActive);
17743 });
17744 }
17745
17746 function rowSelfMatches(row) {
17747 var kind = row.getAttribute("data-kind");
17748 var status = row.getAttribute("data-status");
17749 var language = (row.getAttribute("data-language") || "").toLowerCase();
17750 var name = row.getAttribute("data-name-lower") || "";
17751 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
17752 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
17753 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
17754 var passesLanguage = !activeLanguage || language === activeLanguage;
17755 return passesFilter && passesSearch && passesLanguage;
17756 }
17757
17758 function hasMatchingDescendant(rowId) {
17759 return (childRows[rowId] || []).some(function (childId) {
17760 var childRow = rowById(childId);
17761 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
17762 });
17763 }
17764
17765 function rowMatches(row) {
17766 if (rowSelfMatches(row)) return true;
17767 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
17768 }
17769
17770 function resetViewState() {
17771 activeFilter = "all";
17772 activeLanguage = "";
17773 searchTerm = "";
17774 currentSortKey = null;
17775 currentSortOrder = "asc";
17776 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17777 if (searchInput) searchInput.value = "";
17778 if (filterSelect) filterSelect.value = "all";
17779 updateLanguageButtons();
17780 }
17781
17782 function applyVisibility() {
17783 rows.forEach(function (row) {
17784 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
17785 row.classList.toggle("hidden-by-filter", !visible);
17786 row.style.display = visible ? "grid" : "none";
17787 });
17788 buttons.forEach(function (button) {
17789 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
17790 });
17791 if (filterSelect) filterSelect.value = activeFilter;
17792 }
17793
17794 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
17795 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
17796 var originalStats = {};
17797 buttons.forEach(function (btn) {
17798 var f = btn.getAttribute('data-filter');
17799 var v = btn.querySelector('.scope-stat-value');
17800 if (f && v) originalStats[f] = v.textContent;
17801 });
17802
17803 function applySubmoduleStats(statsJson) {
17804 try {
17805 var s = JSON.parse(statsJson);
17806 buttons.forEach(function (btn) {
17807 var f = btn.getAttribute('data-filter');
17808 var v = btn.querySelector('.scope-stat-value');
17809 if (!v) return;
17810 if (f === 'dir') v.textContent = s.dirs;
17811 else if (f === 'file') v.textContent = s.files;
17812 else if (f === 'supported') v.textContent = s.supported;
17813 else if (f === 'skipped') v.textContent = s.skipped;
17814 else if (f === 'unsupported') v.textContent = s.unsupported;
17815 });
17816 } catch (e) {}
17817 }
17818
17819 function restoreBaseRepoStats() {
17820 buttons.forEach(function (btn) {
17821 var f = btn.getAttribute('data-filter');
17822 var v = btn.querySelector('.scope-stat-value');
17823 if (v && originalStats[f]) v.textContent = originalStats[f];
17824 });
17825 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17826 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
17827 }
17828
17829 submoduleChips.forEach(function (chip) {
17830 chip.addEventListener('click', function () {
17831 var statsJson = chip.getAttribute('data-sub-stats');
17832 if (!statsJson) return;
17833 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
17834 chip.classList.add('active');
17835 applySubmoduleStats(statsJson);
17836 if (baseRepoBtn) baseRepoBtn.style.display = '';
17837 });
17838 });
17839
17840 if (baseRepoBtn) {
17841 baseRepoBtn.addEventListener('click', function () {
17842 restoreBaseRepoStats();
17843 resetViewState();
17844 sortSiblingRows();
17845 applyVisibility();
17846 });
17847 }
17848
17849 buttons.forEach(function (button) {
17850 button.addEventListener("click", function () {
17851 var filterValue = button.getAttribute("data-filter") || "all";
17852 if (filterValue === "reset-view") {
17853 restoreBaseRepoStats();
17854 resetViewState();
17855 sortSiblingRows();
17856 applyVisibility();
17857 return;
17858 }
17859 activeFilter = filterValue;
17860 applyVisibility();
17861 });
17862 });
17863
17864 rows.forEach(function (row) {
17865 updateToggleGlyph(row);
17866 var toggle = row.querySelector(".tree-toggle");
17867 if (toggle) {
17868 toggle.addEventListener("click", function () {
17869 var expanded = row.getAttribute("data-expanded") !== "false";
17870 row.setAttribute("data-expanded", expanded ? "false" : "true");
17871 updateToggleGlyph(row);
17872 applyVisibility();
17873 });
17874 }
17875 });
17876
17877 actionButtons.forEach(function (button) {
17878 button.addEventListener("click", function () {
17879 var action = button.getAttribute("data-explorer-action");
17880 if (action === "expand-all") {
17881 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
17882 } else if (action === "collapse-all") {
17883 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
17884 } else if (action === "clear-filters") {
17885 resetViewState();
17886 }
17887 sortSiblingRows();
17888 applyVisibility();
17889 });
17890 });
17891
17892 if (filterSelect) {
17893 filterSelect.addEventListener("change", function () {
17894 activeFilter = filterSelect.value || "all";
17895 applyVisibility();
17896 });
17897 }
17898
17899 languageButtons.forEach(function (button) {
17900 button.addEventListener("click", function () {
17901 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
17902 updateLanguageButtons();
17903 applyVisibility();
17904 });
17905 });
17906
17907 sortButtons.forEach(function (button) {
17908 button.addEventListener("click", function () {
17909 var sortKey = button.getAttribute("data-sort-key");
17910 if (currentSortKey === sortKey) {
17911 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
17912 } else {
17913 currentSortKey = sortKey;
17914 currentSortOrder = "asc";
17915 }
17916 sortSiblingRows();
17917 applyVisibility();
17918 });
17919 });
17920
17921 if (searchInput) {
17922 searchInput.addEventListener("input", function () {
17923 searchTerm = searchInput.value.trim().toLowerCase();
17924 applyVisibility();
17925 });
17926 }
17927
17928 updateLanguageButtons();
17929 sortSiblingRows();
17930 applyVisibility();
17931 }
17932
17933 function loadPreview() {
17934 if (!previewPanel || !pathInput) return;
17935 if (GIT_MODE) {
17936 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>';
17937 return;
17938 }
17939 var path = pathInput.value.trim();
17940 var zeroWarn = document.getElementById('zero-files-warning');
17941 if (!path) {
17942 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
17943 if (zeroWarn) zeroWarn.style.display = 'none';
17944 return;
17945 }
17946 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
17947 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
17948 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
17949 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
17950 var myGen = ++_previewGen;
17951 var _prevMsgs = [
17952 'Scanning directory structure…',
17953 'Detecting file types…',
17954 'Applying include / exclude filters…',
17955 'Estimating file counts…',
17956 'Building scope preview…',
17957 'Almost there…'
17958 ];
17959 var _prevMsgIdx = 0;
17960 var _prevStart = Date.now();
17961 previewPanel.innerHTML =
17962 '<div class="preview-loading">' +
17963 '<div class="preview-spinner"></div>' +
17964 '<div class="preview-loading-text">' +
17965 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
17966 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
17967 '</div></div>';
17968 var _sizeTextEl = document.getElementById('project-size-text');
17969 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
17970 window._previewInterval = setInterval(function() {
17971 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
17972 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
17973 var ml = document.getElementById('plm');
17974 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
17975 }, 1500);
17976 window._previewElapsedTimer = setInterval(function() {
17977 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
17978 var el = document.getElementById('ple');
17979 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
17980 }, 1000);
17981 var previewUrl = "/preview?path=" + encodeURIComponent(path)
17982 + "&include_globs=" + encodeURIComponent(includeValue)
17983 + "&exclude_globs=" + encodeURIComponent(excludeValue);
17984 fetch(previewUrl)
17985 .then(function (response) { return response.text(); })
17986 .then(function (html) {
17987 if (myGen !== _previewGen) return;
17988 clearInterval(window._previewInterval); window._previewInterval = null;
17989 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
17990 previewPanel.innerHTML = html;
17991 attachPreviewInteractions();
17992 syncPythonVisibility();
17993 updateReview();
17994 setTimeout(collapseLanguagePills, 50);
17995 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
17996 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
17997 var sizeText = document.getElementById('project-size-text');
17998 var sizeBtn = document.getElementById('project-size-btn');
17999 // In server mode with upload sizes available, keep the compressed/original pair.
18000 if (SERVER_MODE && window._lastUploadSizes) {
18001 var us = window._lastUploadSizes;
18002 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
18003 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
18004 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
18005 ' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
18006 } else if (sizeText && projectSize) {
18007 sizeText.textContent = 'Project size: ' + projectSize;
18008 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
18009 } else if (sizeText) {
18010 sizeText.textContent = 'Project size: —';
18011 }
18012 if (zeroWarn) {
18013 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
18014 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
18015 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
18016 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
18017 if (supportedCount === 0 && fileCount > 0) {
18018 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).';
18019 zeroWarn.style.display = '';
18020 } else {
18021 zeroWarn.style.display = 'none';
18022 }
18023 }
18024 })
18025 .catch(function (err) {
18026 if (myGen !== _previewGen) return;
18027 clearInterval(window._previewInterval); window._previewInterval = null;
18028 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
18029 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
18030 });
18031 }
18032
18033 function pickDirectory(targetInput, kind) {
18034 if (!targetInput) {
18035 showBannerToast("Directory picker: input element not found.", true);
18036 return;
18037 }
18038 if (SERVER_MODE) {
18039 if (kind === 'output') {
18040 showBannerToast(
18041 'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
18042 false,
18043 { top: true, icon: '📁' }
18044 );
18045 return;
18046 }
18047 var inputEl = kind === 'coverage'
18048 ? document.getElementById('cov-upload-input')
18049 : document.getElementById('dir-upload-input');
18050 if (!inputEl) return;
18051 inputEl.onchange = function () {
18052 var files = inputEl.files;
18053 if (!files || files.length === 0) return;
18054 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
18055 if (browseBtn) browseBtn.disabled = true;
18056
18057 function fileToBase64(file) {
18058 return new Promise(function (resolve, reject) {
18059 var reader = new FileReader();
18060 reader.onload = function () {
18061 var b64 = reader.result.split(',')[1];
18062 resolve(b64);
18063 };
18064 reader.onerror = reject;
18065 reader.readAsDataURL(file);
18066 });
18067 }
18068
18069 if (kind === 'coverage') {
18070 var f = files[0];
18071 if (previewPanel && targetInput === pathInput)
18072 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
18073 fileToBase64(f).then(function (b64) {
18074 return fetch('/api/upload-file', {
18075 method: 'POST',
18076 headers: { 'Content-Type': 'application/json' },
18077 body: JSON.stringify({ filename: f.name, content: b64 })
18078 }).then(function (r) { return r.json(); });
18079 })
18080 .then(function (d) {
18081 if (d && d.tmp_path) {
18082 if (coverageInput) coverageInput.value = d.tmp_path;
18083 setCovStatus('idle');
18084 } else if (d && d.error) { showBannerToast(d.error, true); }
18085 })
18086 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
18087 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
18088 } else {
18089 // ── Filter to source-code files only ─────────────────────────
18090 // Binary, generated, and dependency files (node_modules, .git,
18091 // build artifacts) are skipped so they are never uploaded.
18092 var CODE_EXTS = new Set([
18093 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18094 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18095 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18096 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18097 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18098 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
18099 'tf','hcl','proto','thrift','avsc','graphql','gql'
18100 ]);
18101 var codeFiles = [];
18102 for (var i = 0; i < files.length; i++) {
18103 var f = files[i];
18104 var name = f.name;
18105 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
18106 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
18107 codeFiles.push(f); continue;
18108 }
18109 var dot = name.lastIndexOf('.');
18110 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
18111 }
18112 // Collect specific .git metadata files for server-side git detection.
18113 // These have no source extension so they are excluded by the loop above,
18114 // but the server needs them to read branch/commit/author without running git.
18115 var gitMetaFiles = [];
18116 for (var i = 0; i < files.length; i++) {
18117 var f = files[i];
18118 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
18119 var gitIdx = rp.indexOf('/.git/');
18120 if (gitIdx < 0) continue;
18121 var gitRel = rp.slice(gitIdx + 1);
18122 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
18123 gitRel === '.git/logs/HEAD' ||
18124 gitRel.startsWith('.git/refs/heads/') ||
18125 gitRel.startsWith('.git/refs/tags/')) {
18126 gitMetaFiles.push(f);
18127 }
18128 }
18129 var uploadFiles = codeFiles.concat(gitMetaFiles);
18130 var total = files.length;
18131 var kept = codeFiles.length;
18132 if (kept === 0) {
18133 if (previewPanel && targetInput === pathInput)
18134 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
18135 if (browseBtn) browseBtn.disabled = false;
18136 inputEl.value = '';
18137 return;
18138 }
18139
18140 // ── Helper: apply upload result to UI ────────────────────────
18141 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
18142 function applyUploadResult(tmpPath, sizes) {
18143 targetInput.value = tmpPath;
18144 scrollInputToEnd(targetInput);
18145 if (sizes && SERVER_MODE) {
18146 window._lastUploadSizes = sizes;
18147 // Immediately show both sizes before preview loads.
18148 var sizeText = document.getElementById('project-size-text');
18149 var sizeBtn = document.getElementById('project-size-btn');
18150 if (sizeText) {
18151 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18152 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18153 }
18154 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18155 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18156 }
18157 if (targetInput === pathInput) {
18158 updateReportTitleFromPath();
18159 autoSetOutputDir(tmpPath);
18160 fetchProjectHistory(tmpPath);
18161 loadPreview();
18162 suggestCoverageFile(tmpPath);
18163 }
18164 updateReview();
18165 if (browseBtn) browseBtn.disabled = false;
18166 inputEl.value = '';
18167 }
18168
18169 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
18170 if (typeof CompressionStream !== 'undefined') {
18171 if (previewPanel && targetInput === pathInput)
18172 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18173
18174 // Build a minimal POSIX ustar tar header for a single file entry.
18175 function buildUstarHeader(filePath, fileSize) {
18176 var BLOCK = 512;
18177 var hdr = new Uint8Array(BLOCK);
18178 var enc = new TextEncoder();
18179 function wStr(off, len, s) {
18180 var b = enc.encode(s);
18181 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
18182 }
18183 function wOct(off, len, val) {
18184 var s = val.toString(8);
18185 while (s.length < len - 1) s = '0' + s;
18186 wStr(off, len, s + '\0');
18187 }
18188 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
18189 var name = filePath, prefix = '';
18190 if (filePath.length > 99) {
18191 var split = filePath.lastIndexOf('/', 154);
18192 if (split > 0 && filePath.length - split - 1 <= 99) {
18193 prefix = filePath.substring(0, split);
18194 name = filePath.substring(split + 1);
18195 } else { name = filePath.substring(0, 99); }
18196 }
18197 wStr(0, 100, name); // name
18198 wOct(100, 8, 0o000644); // mode
18199 wOct(108, 8, 0); // uid
18200 wOct(116, 8, 0); // gid
18201 wOct(124, 12, fileSize); // size
18202 wOct(136, 12, 0); // mtime (epoch)
18203 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
18204 hdr[156] = 48; // type flag '0' = regular file
18205 wStr(157, 100, ''); // linkname
18206 wStr(257, 6, 'ustar'); // magic
18207 wStr(263, 2, '00'); // version
18208 wStr(265, 32, ''); // uname
18209 wStr(297, 32, ''); // gname
18210 wOct(329, 8, 0); // devmajor
18211 wOct(337, 8, 0); // devminor
18212 wStr(345, 155, prefix); // prefix
18213 // Compute checksum (sum of all bytes, placeholder = 32).
18214 var chk = 0;
18215 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18216 var cs = chk.toString(8);
18217 while (cs.length < 6) cs = '0' + cs;
18218 wStr(148, 8, cs + '\0 ');
18219 return hdr;
18220 }
18221
18222 // Build tar.gz one file at a time, piping through CompressionStream.
18223 // RAM usage = compressed output buffer + one file at a time.
18224 (async function () {
18225 try {
18226 var BLOCK = 512;
18227 var cs = new CompressionStream('gzip');
18228 var writer = cs.writable.getWriter();
18229 var chunks = [];
18230 var reader = cs.readable.getReader();
18231 var collecting = (async function () {
18232 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
18233 })();
18234
18235 for (var i = 0; i < uploadFiles.length; i++) {
18236 var file = uploadFiles[i];
18237 var path = file.webkitRelativePath || file.name;
18238 var buf = await file.arrayBuffer();
18239 var data = new Uint8Array(buf);
18240 // Header block
18241 await writer.write(buildUstarHeader(path, data.length));
18242 // Data padded to 512-byte boundary
18243 if (data.length > 0) {
18244 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
18245 var block = new Uint8Array(padded);
18246 block.set(data);
18247 await writer.write(block);
18248 }
18249 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
18250 if (previewPanel && targetInput === pathInput)
18251 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18252 }
18253 }
18254 // End-of-archive: two 512-byte zero blocks
18255 await writer.write(new Uint8Array(BLOCK * 2));
18256 await writer.close();
18257 await collecting;
18258
18259 var blob = new Blob(chunks, { type: 'application/gzip' });
18260 var sizeMB = (blob.size / 1048576).toFixed(1);
18261 if (previewPanel && targetInput === pathInput)
18262 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
18263
18264 var resp = await fetch('/api/upload-tarball', {
18265 method: 'POST',
18266 headers: { 'Content-Type': 'application/gzip' },
18267 body: blob
18268 });
18269 var d = await resp.json();
18270 if (d && d.tmp_path) {
18271 applyUploadResult(d.tmp_path, {
18272 compressed_bytes: d.compressed_bytes || 0,
18273 original_bytes: d.original_bytes || 0
18274 });
18275 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18276 } catch (e) {
18277 showBannerToast('Upload failed: ' + String(e), true);
18278 if (browseBtn) browseBtn.disabled = false;
18279 inputEl.value = '';
18280 }
18281 })();
18282
18283 } else {
18284 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
18285 // Used only on browsers that lack CompressionStream (pre-2023).
18286 var BATCH = 200;
18287 var batches = [];
18288 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
18289 var totalBatches = batches.length;
18290 if (previewPanel && targetInput === pathInput)
18291 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
18292
18293 function sendBatch(idx, currentUploadId, lastTmpPath) {
18294 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
18295 if (previewPanel && targetInput === pathInput && totalBatches > 1)
18296 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
18297 Promise.all(batches[idx].map(function (file) {
18298 return fileToBase64(file).then(function (b64) {
18299 return { path: file.webkitRelativePath || file.name, content: b64 };
18300 });
18301 })).then(function (fileList) {
18302 var body = { files: fileList };
18303 if (currentUploadId) body.upload_id = currentUploadId;
18304 return fetch('/api/upload-directory', {
18305 method: 'POST', headers: { 'Content-Type': 'application/json' },
18306 body: JSON.stringify(body)
18307 }).then(function (r) { return r.json(); });
18308 }).then(function (d) {
18309 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
18310 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
18311 }).catch(function (e) {
18312 showBannerToast('Upload failed: ' + String(e), true);
18313 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
18314 });
18315 }
18316 sendBatch(0, null, '');
18317 }
18318 }
18319 };
18320 inputEl.click();
18321 return;
18322 }
18323
18324 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
18325 if (browseButton) browseButton.disabled = true;
18326
18327 if (previewPanel && targetInput === pathInput) {
18328 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
18329 }
18330
18331 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
18332 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
18333 .then(function (data) {
18334 if (data && data.selected_path) {
18335 targetInput.value = data.selected_path;
18336 scrollInputToEnd(targetInput);
18337
18338 if (targetInput === pathInput) {
18339 updateReportTitleFromPath();
18340 autoSetOutputDir(data.selected_path);
18341 fetchProjectHistory(data.selected_path);
18342 loadPreview();
18343 suggestCoverageFile(data.selected_path);
18344 }
18345
18346 updateReview();
18347 } else if (targetInput === pathInput) {
18348 loadPreview();
18349 }
18350 })
18351 .catch(function () {
18352 window.alert("Directory picker request failed.");
18353 if (previewPanel && targetInput === pathInput) {
18354 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
18355 }
18356 })
18357 .finally(function () {
18358 if (browseButton) browseButton.disabled = false;
18359 });
18360 }
18361
18362 if (themeToggle) {
18363 themeToggle.addEventListener("click", function () {
18364 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
18365 applyTheme(nextTheme);
18366 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
18367 });
18368 }
18369
18370 stepButtons.forEach(function (button) {
18371 button.addEventListener("click", function () {
18372 setStep(Number(button.getAttribute("data-step-target")));
18373 });
18374 });
18375
18376 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
18377 button.addEventListener("click", function () {
18378 setStep(Number(button.getAttribute("data-step-target")) || 1);
18379 });
18380 });
18381
18382 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
18383 button.addEventListener("click", function () {
18384 updateReview();
18385 setStep(Number(button.getAttribute("data-next")));
18386 });
18387 });
18388
18389 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
18390 button.addEventListener("click", function () {
18391 setStep(Number(button.getAttribute("data-prev")));
18392 });
18393 });
18394
18395 document.addEventListener("keydown", function (e) {
18396 var tag = (document.activeElement || {}).tagName || "";
18397 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
18398 if (e.altKey || e.ctrlKey || e.metaKey) return;
18399 if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
18400 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
18401 });
18402
18403 if (useSamplePath) {
18404 useSamplePath.addEventListener("click", function () {
18405 pathInput.value = "tests/fixtures/basic";
18406 updateReportTitleFromPath();
18407 autoSetOutputDir("tests/fixtures/basic");
18408 loadPreview();
18409 suggestCoverageFile("tests/fixtures/basic");
18410 });
18411 }
18412
18413 if (useDefaultOutput) {
18414 useDefaultOutput.addEventListener("click", function () {
18415 delete outputDirInput.dataset.userEdited;
18416 autoSetOutputDir(pathInput ? pathInput.value : "");
18417 updateReview();
18418 });
18419 }
18420
18421 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
18422 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
18423
18424 // ── Drag-and-drop directory upload (server mode only) ─────────────────
18425 // Dropping a folder onto the path field bypasses Chrome's
18426 // "Upload X files to this site?" confirmation dialog.
18427 async function readDirRecursively(dirEntry, basePath) {
18428 var reader = dirEntry.createReader();
18429 var all = [];
18430 for (;;) {
18431 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
18432 if (!batch.length) break;
18433 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
18434 }
18435 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
18436 var out = [];
18437 for (var i = 0; i < all.length; i++) {
18438 var sub = all[i];
18439 if (sub.isFile) {
18440 var f = await new Promise(function(res) { sub.file(res); });
18441 out.push({ file: f, path: basePath + '/' + sub.name });
18442 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
18443 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
18444 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
18445 }
18446 }
18447 return out;
18448 }
18449
18450 function setupPathDropZone() {
18451 if (!SERVER_MODE || !pathInput) return;
18452 var CODE_EXTS = new Set([
18453 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
18454 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
18455 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
18456 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
18457 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
18458 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
18459 ]);
18460 pathInput.addEventListener('dragover', function(e) {
18461 e.preventDefault();
18462 pathInput.classList.add('drag-over');
18463 });
18464 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
18465 pathInput.addEventListener('drop', function(e) {
18466 e.preventDefault();
18467 pathInput.classList.remove('drag-over');
18468 var items = e.dataTransfer.items;
18469 if (!items || !items.length) return;
18470 var dirEntry = null;
18471 for (var i = 0; i < items.length; i++) {
18472 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
18473 if (entry && entry.isDirectory) { dirEntry = entry; break; }
18474 }
18475 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
18476 var btn = browsePath;
18477 if (btn) btn.disabled = true;
18478 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
18479
18480 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
18481 var total = allEntries.length;
18482 var codeEntries = allEntries.filter(function(e) {
18483 var n = e.file.name;
18484 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
18485 var dot = n.lastIndexOf('.');
18486 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
18487 });
18488 var kept = codeEntries.length;
18489 if (kept === 0) {
18490 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
18491 if (btn) btn.disabled = false; return;
18492 }
18493
18494 function finish(tmpPath, sizes) {
18495 pathInput.value = tmpPath;
18496 scrollInputToEnd(pathInput);
18497 if (sizes) {
18498 window._lastUploadSizes = sizes;
18499 var sizeText = document.getElementById('project-size-text');
18500 var sizeBtn = document.getElementById('project-size-btn');
18501 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
18502 ' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
18503 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
18504 ' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
18505 }
18506 updateReportTitleFromPath();
18507 autoSetOutputDir(tmpPath);
18508 fetchProjectHistory(tmpPath);
18509 loadPreview();
18510 suggestCoverageFile(tmpPath);
18511 updateReview();
18512 if (btn) btn.disabled = false;
18513 }
18514
18515 if (typeof CompressionStream === 'undefined') {
18516 showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
18517 if (btn) btn.disabled = false; return;
18518 }
18519
18520 try {
18521 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
18522 var BLOCK = 512;
18523 var cs = new CompressionStream('gzip');
18524 var wtr = cs.writable.getWriter();
18525 var chunks = [];
18526 var rdr = cs.readable.getReader();
18527 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
18528
18529 function buildHdr(fp, sz) {
18530 var hdr = new Uint8Array(BLOCK);
18531 var enc = new TextEncoder();
18532 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]; }
18533 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
18534 var nm = fp, pfx = '';
18535 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); } }
18536 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
18537 for (var i = 148; i < 156; i++) hdr[i] = 32;
18538 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);
18539 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
18540 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
18541 return hdr;
18542 }
18543
18544 for (var i = 0; i < codeEntries.length; i++) {
18545 var ce = codeEntries[i];
18546 var buf = await ce.file.arrayBuffer();
18547 var data = new Uint8Array(buf);
18548 await wtr.write(buildHdr(ce.path, data.length));
18549 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
18550 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
18551 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
18552 }
18553 await wtr.write(new Uint8Array(BLOCK * 2));
18554 await wtr.close();
18555 await collecting;
18556
18557 var blob = new Blob(chunks, { type: 'application/gzip' });
18558 var sizeMB = (blob.size / 1048576).toFixed(1);
18559 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
18560 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
18561 var d = await resp.json();
18562 if (d && d.tmp_path) {
18563 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
18564 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
18565 } catch (err) {
18566 showBannerToast('Upload failed: ' + String(err), true);
18567 if (btn) btn.disabled = false;
18568 }
18569 }).catch(function(err) {
18570 showBannerToast('Could not read folder: ' + String(err), true);
18571 if (btn) btn.disabled = false;
18572 });
18573 });
18574 }
18575 setupPathDropZone();
18576 if (browseCoverage) {
18577 browseCoverage.addEventListener("click", function () {
18578 pickDirectory(coverageInput || pathInput, "coverage");
18579 });
18580 }
18581
18582 function setCovStatus(state, opts) {
18583 if (!covScanStatus) return;
18584 opts = opts || {};
18585 covScanStatus.className = "cov-scan-status cov-scan-" + state;
18586 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
18587 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>';
18588 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>';
18589 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>';
18590 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>';
18591 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
18592 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
18593 if (state === "scanning") {
18594 html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
18595 } else if (state === "found") {
18596 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18597 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
18598 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
18599 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
18600 } else if (state === "hint") {
18601 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
18602 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
18603 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>';
18604 } else if (state === "none") {
18605 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
18606 html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
18607 }
18608 html += '</div></div>';
18609 covScanStatus.innerHTML = html;
18610 if (state === "found") {
18611 var useBtn = covScanStatus.querySelector(".cov-scan-use");
18612 if (useBtn) useBtn.addEventListener("click", function () {
18613 if (coverageInput) coverageInput.value = "";
18614 covAutoFilled = false;
18615 setCovStatus("idle");
18616 });
18617 }
18618 }
18619
18620 function suggestCoverageFile(projectPath) {
18621 if (!coverageInput || !covScanStatus) return;
18622 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18623 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
18624 clearTimeout(coverageSuggestTimer);
18625 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
18626 setCovStatus("scanning");
18627 coverageSuggestTimer = setTimeout(function () {
18628 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
18629 .then(function (r) { return r.json(); })
18630 .then(function (d) {
18631 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
18632 if (!d) { setCovStatus("none"); return; }
18633 if (d.found) {
18634 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
18635 setCovStatus("found", { found: d.found, tool: d.tool });
18636 } else if (d.tool && d.hint) {
18637 setCovStatus("hint", { tool: d.tool, hint: d.hint });
18638 } else {
18639 setCovStatus("none");
18640 }
18641 })
18642 .catch(function () { setCovStatus("idle"); });
18643 }, 600);
18644 }
18645
18646 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
18647
18648 if (coverageInput) coverageInput.addEventListener("input", function () {
18649 covAutoFilled = false;
18650 if (!this.value.trim()) setCovStatus("idle");
18651 });
18652
18653 // ── Language pill overflow: collapse to "+N more" chip ─────────────
18654 function collapseLanguagePills() {
18655 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
18656 rows.forEach(function(row) {
18657 // Remove any previous overflow chip
18658 var prev = row.querySelector('.lang-overflow-chip');
18659 if (prev) prev.remove();
18660 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
18661 pills.forEach(function(p) { p.style.display = ''; });
18662 if (!pills.length) return;
18663
18664 // Measure after restoring all pills
18665 var containerRight = row.getBoundingClientRect().right;
18666 var hidden = [];
18667 for (var i = pills.length - 1; i >= 1; i--) {
18668 var rect = pills[i].getBoundingClientRect();
18669 if (rect.right > containerRight + 2) {
18670 hidden.unshift(pills[i]);
18671 pills[i].style.display = 'none';
18672 } else {
18673 break;
18674 }
18675 }
18676
18677 if (hidden.length) {
18678 var chip = document.createElement('button');
18679 chip.type = 'button';
18680 chip.className = 'language-pill lang-overflow-chip';
18681 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
18682 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
18683 row.appendChild(chip);
18684 }
18685 });
18686 }
18687
18688 // Run after preview loads (preview panel populates language pills)
18689 var _origLoadPreviewCb = window.__previewLoaded;
18690 document.addEventListener('previewLoaded', collapseLanguagePills);
18691 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
18692 setTimeout(collapseLanguagePills, 400);
18693
18694 // ── Project history & output dir auto-set ──────────────────────────
18695 var wsOutputRoot = document.getElementById("ws-output-root");
18696 var wsScanCount = document.getElementById("ws-scan-count");
18697 var wsLastScan = document.getElementById("ws-last-scan");
18698 var historyBadge = document.getElementById("path-history-badge");
18699 var historyTimer = null;
18700
18701 var wsOutputLink = document.getElementById("ws-output-link");
18702 function syncStripOutputRoot() {
18703 var val = outputDirInput ? outputDirInput.value : "";
18704 var display = val || "project/sloc";
18705 if (wsOutputRoot) wsOutputRoot.textContent = display;
18706 if (wsOutputLink) wsOutputLink.dataset.folder = val;
18707 }
18708
18709 function scrollInputToEnd(input) {
18710 if (!input) return;
18711 // Defer so the DOM has the new value before we measure scroll width.
18712 requestAnimationFrame(function () {
18713 input.scrollLeft = input.scrollWidth;
18714 input.selectionStart = input.selectionEnd = input.value.length;
18715 });
18716 }
18717
18718 function autoSetOutputDir(projectPath) {
18719 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
18720 if (GIT_MODE && GIT_OUTPUT_DIR) {
18721 outputDirInput.value = GIT_OUTPUT_DIR;
18722 scrollInputToEnd(outputDirInput);
18723 syncStripOutputRoot();
18724 updateReview();
18725 return;
18726 }
18727 if (!projectPath || !projectPath.trim()) return;
18728 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
18729 outputDirInput.value = cleaned + "/sloc";
18730 scrollInputToEnd(outputDirInput);
18731 syncStripOutputRoot();
18732 updateReview();
18733 }
18734
18735 var wsBranch = document.getElementById("ws-branch");
18736
18737 function fetchProjectHistory(projectPath) {
18738 if (!projectPath || !projectPath.trim()) {
18739 if (wsScanCount) wsScanCount.textContent = "—";
18740 if (wsLastScan) wsLastScan.textContent = "—";
18741 if (wsBranch) wsBranch.textContent = "—";
18742 if (historyBadge) historyBadge.style.display = "none";
18743 return;
18744 }
18745 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
18746 .then(function (r) { return r.ok ? r.json() : null; })
18747 .then(function (data) {
18748 if (!data) return;
18749 var countStr = data.scan_count > 0
18750 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
18751 : "never";
18752 var tsStr = data.last_scan_timestamp
18753 ? data.last_scan_timestamp.replace(" UTC","")
18754 : "—";
18755 if (wsScanCount) wsScanCount.textContent = countStr;
18756 if (wsLastScan) wsLastScan.textContent = tsStr;
18757 if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
18758 if (data.scan_count > 0) {
18759 if (historyBadge) {
18760 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
18761 historyBadge.textContent = data.scan_count + " previous scan" +
18762 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
18763 "Last: " + (data.last_scan_timestamp || "—") +
18764 " — " + (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.";
18765 historyBadge.className = "path-history-badge found";
18766 historyBadge.style.display = "";
18767 }
18768 } else {
18769 if (historyBadge) historyBadge.style.display = "none";
18770 }
18771 })
18772 .catch(function () {});
18773 }
18774
18775 function onPathChange() {
18776 var val = pathInput ? pathInput.value : "";
18777 // Discard stale upload sizes when the user edits the path manually.
18778 window._lastUploadSizes = null;
18779 updateReportTitleFromPath();
18780 autoSetOutputDir(val);
18781 updateSidebarSummary();
18782 clearTimeout(historyTimer);
18783 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
18784 if (previewTimer) clearTimeout(previewTimer);
18785 previewTimer = setTimeout(loadPreview, 280);
18786 suggestCoverageFile(val);
18787 }
18788
18789 if (pathInput) {
18790 pathInput.addEventListener("input", onPathChange);
18791 }
18792
18793 if (outputDirInput) {
18794 outputDirInput.addEventListener("input", function () {
18795 outputDirInput.dataset.userEdited = "1";
18796 syncStripOutputRoot();
18797 updateReview();
18798 });
18799 }
18800
18801 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
18802 if (!node) return;
18803 node.addEventListener("input", function () {
18804 updateReview();
18805 if (previewTimer) clearTimeout(previewTimer);
18806 previewTimer = setTimeout(loadPreview, 280);
18807 });
18808 });
18809
18810 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
18811 var node = document.getElementById(id);
18812 if (node) node.addEventListener("change", updateReview);
18813 });
18814
18815 if (reportTitleInput) {
18816 reportTitleInput.addEventListener("input", function () {
18817 reportTitleTouched = reportTitleInput.value.trim().length > 0;
18818 updateReportTitleFromPath();
18819 updateReview();
18820 });
18821 }
18822
18823 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
18824 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
18825 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
18826 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
18827
18828 if (coverageInput) {
18829 coverageInput.addEventListener("input", function () {
18830 if (coverageInput.value.trim()) setCovStatus("idle");
18831 });
18832 }
18833
18834 if (form && loading && submitButton) {
18835 form.addEventListener("submit", function (e) {
18836 e.preventDefault();
18837 submitButton.disabled = true;
18838 submitButton.textContent = "Scanning...";
18839 startAsyncAnalysis(new FormData(form));
18840 });
18841 }
18842
18843 function openPath(folder) {
18844 if (!folder) return;
18845 fetch('/open-path?path=' + encodeURIComponent(folder))
18846 .then(function (r) { return r.json(); })
18847 .then(function (d) {
18848 if (d && d.server_mode_disabled)
18849 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
18850 })
18851 .catch(function () {});
18852 }
18853
18854 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
18855 btn.addEventListener('click', function () {
18856 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
18857 });
18858 });
18859
18860 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
18861 if (wsOutputLink) {
18862 wsOutputLink.addEventListener('click', function () {
18863 openPath(wsOutputLink.dataset.folder || '');
18864 });
18865 }
18866
18867 loadSavedTheme();
18868 updateMixedPolicyUI();
18869 updatePythonDocstringUI();
18870 applyScanPreset();
18871 updatePresetDescriptions();
18872 applyArtifactPreset();
18873 updateReview();
18874 updateScrollProgress(); // initialise bar to 0% (step 1)
18875 window.addEventListener("scroll", updateScrollProgress, { passive: true });
18876 onPathChange(); // seed output dir, history badge, and preview from initial path
18877 updateStepNav(1);
18878
18879 // Restore step from URL hash on initial load (e.g., back-forward cache)
18880 (function() {
18881 var hashMatch = location.hash.match(/^#step([1-4])$/);
18882 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
18883 })();
18884
18885 (function randomizeWatermarks() {
18886 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
18887 if (!wms.length) return;
18888 var placed = [];
18889 function tooClose(top, left) {
18890 for (var i = 0; i < placed.length; i++) {
18891 var dt = Math.abs(placed[i][0] - top);
18892 var dl = Math.abs(placed[i][1] - left);
18893 if (dt < 16 && dl < 12) return true;
18894 }
18895 return false;
18896 }
18897 function pick(leftBand) {
18898 for (var attempt = 0; attempt < 50; attempt++) {
18899 var top = Math.random() * 88 + 2;
18900 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18901 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18902 }
18903 var top = Math.random() * 88 + 2;
18904 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18905 placed.push([top, left]);
18906 return [top, left];
18907 }
18908 var half = Math.floor(wms.length / 2);
18909 wms.forEach(function (img, i) {
18910 var pos = pick(i < half);
18911 var size = Math.floor(Math.random() * 80 + 110);
18912 var rot = (Math.random() * 360).toFixed(1);
18913 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
18914 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;
18915 });
18916 })();
18917
18918 (function spawnCodeParticles() {
18919 var container = document.getElementById('code-particles');
18920 if (!container) return;
18921 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'];
18922 for (var i = 0; i < 38; i++) {
18923 (function(idx) {
18924 var el = document.createElement('span');
18925 el.className = 'code-particle';
18926 el.textContent = snippets[idx % snippets.length];
18927 var left = Math.random() * 94 + 2;
18928 var top = Math.random() * 88 + 6;
18929 var dur = (Math.random() * 10 + 9).toFixed(1);
18930 var delay = (Math.random() * 18).toFixed(1);
18931 var rot = (Math.random() * 26 - 13).toFixed(1);
18932 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18933 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';
18934 container.appendChild(el);
18935 })(i);
18936 }
18937 })();
18938 })();
18939 </script>
18940 <script nonce="{{ csp_nonce }}">
18941 (function () {
18942 var raw = {{ prefill_json|safe }};
18943 if (!raw || typeof raw !== 'object' || !raw.path) return;
18944 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
18945 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
18946 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
18947 setVal('path', raw.path || '');
18948 setVal('include_globs', raw.include_globs || '');
18949 setVal('exclude_globs', raw.exclude_globs || '');
18950 setVal('output_dir', raw.output_dir || '');
18951 setVal('report_title', raw.report_title || '');
18952 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
18953 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
18954 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
18955 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
18956 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
18957 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
18958 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
18959 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
18960 setChecked('generate_html', raw.generate_html !== false);
18961 setChecked('generate_pdf', !!raw.generate_pdf);
18962 // Trigger dynamic UI updates after pre-fill.
18963 setTimeout(function () {
18964 var pathEl = document.getElementById('path');
18965 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
18966 var policyEl = document.getElementById('mixed_line_policy');
18967 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
18968 }, 80);
18969 })();
18970 </script>
18971 <script nonce="{{ csp_nonce }}">
18972 (function(){
18973 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'}];
18974 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);});}
18975 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18976 function init(){
18977 var btn=document.getElementById('settings-btn');if(!btn)return;
18978 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18979 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>';
18980 document.body.appendChild(m);
18981 var g=document.getElementById('scheme-grid');
18982 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);});
18983 var cl=document.getElementById('settings-close');
18984 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);
18985 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');});
18986 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18987 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18988 }
18989 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18990 }());
18991 </script>
18992 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
18993 <div class="wb-ftip-arrow"></div>
18994 <span id="wb-ftip-text"></span>
18995 </div>
18996 <script nonce="{{ csp_nonce }}">(function(){
18997 var tip=document.getElementById('wb-ftip');
18998 var txt=document.getElementById('wb-ftip-text');
18999 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
19000 if(!tip||!txt)return;
19001 function pos(el){
19002 var r=el.getBoundingClientRect();
19003 tip.style.display='block';
19004 var tw=tip.offsetWidth;
19005 var lx=r.left+r.width/2-tw/2;
19006 if(lx<8)lx=8;
19007 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
19008 tip.style.left=lx+'px';
19009 tip.style.top=(r.bottom+8)+'px';
19010 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';}
19011 }
19012 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
19013 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
19014 el.addEventListener('mouseleave',function(){tip.style.display='none';});
19015 });
19016 window.addEventListener('blur',function(){tip.style.display='none';});
19017 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
19018 })();
19019 (function(){
19020 function fixArtifactHintSpacing(){
19021 var grid=document.querySelector('.artifact-grid');
19022 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
19023 }
19024 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
19025 }());
19026 (function(){
19027 var dot=document.getElementById('status-dot');
19028 var pingEl=document.getElementById('server-ping-ms');
19029 var tipEl=document.getElementById('server-tip-ping');
19030 var fm=document.getElementById('footer-mode');
19031 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)';}}
19032 function doPing(){
19033 var t0=performance.now();
19034 fetch('/healthz',{cache:'no-store'})
19035 .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);})
19036 .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)';}});
19037 }
19038 doPing();
19039 setInterval(doPing,5000);
19040 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');}
19041 })();
19042 </script>
19043 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
19044 <footer class="site-footer">
19045 local code analysis - metrics, history and reports
19046 · <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>
19047 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19048 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19049 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19050 · <a href="/api-docs" rel="noopener">REST API</a>
19051 </footer>
19052</body>
19053</html>
19054"##,
19055 ext = "html"
19056)]
19057struct IndexTemplate {
19058 version: &'static str,
19059 prefill_json: String,
19060 csp_nonce: String,
19061 git_repo: String,
19062 git_ref: String,
19063 git_label_json: String,
19064 git_output_dir_json: String,
19065 server_mode: bool,
19066}
19067
19068#[derive(Template)]
19071#[template(
19072 source = r##"
19073<!doctype html>
19074<html lang="en">
19075<head>
19076 <meta charset="utf-8">
19077 <meta name="viewport" content="width=device-width, initial-scale=1">
19078 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
19079 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19080 <script type="application/ld+json">
19081 {
19082 "@context": "https://schema.org",
19083 "@type": "SoftwareApplication",
19084 "name": "oxide-sloc",
19085 "applicationCategory": "DeveloperApplication",
19086 "operatingSystem": "Windows, Linux",
19087 "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.",
19088 "softwareVersion": "{{ version }}",
19089 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
19090 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
19091 "url": "https://github.com/oxide-sloc/oxide-sloc",
19092 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
19093 "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",
19094 "programmingLanguage": "Rust",
19095 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
19096 }
19097 </script>
19098 <style nonce="{{ csp_nonce }}">
19099 :root {
19100 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
19101 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19102 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19103 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19104 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19105 }
19106 body.dark-theme {
19107 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19108 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19109 }
19110 *{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;}
19111 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19112 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19113 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19114 .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;}
19115 @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));}}
19116 .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);}
19117 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19118 .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));}
19119 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
19120 .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;}
19121 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19122 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19123 @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; } }
19124 .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;}
19125 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19126 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19127 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19128 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19129 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19130 .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;}
19131 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19132 .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);}
19133 .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;}
19134 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19135 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19136 .settings-modal-body{padding:14px 16px 16px;}
19137 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19138 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19139 .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;}
19140 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19141 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19142 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19143 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19144 .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;}
19145 .tz-select:focus{border-color:var(--oxide);}
19146 .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;}
19147 .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;}
19148 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
19149 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
19150 .hero{text-align:center;margin:0 auto 18px;}
19151 .hero-logo-wrap{display:inline-block;cursor:default;}
19152 .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;}
19153 .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;}
19154 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
19155 .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;}
19156 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%);}
19157 .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;
19158 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
19159 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
19160 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;}
19161 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
19162 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
19163 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;}
19164 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
19165 .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;}
19166 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
19167 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
19168 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
19169 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
19170 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
19171 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
19172 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
19173 .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;}
19174 .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;}
19175 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19176 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
19177 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19178 .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);}
19179 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
19180 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
19181 .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);}
19182 .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);}
19183 .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);}
19184 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
19185 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
19186 .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;}
19187 body.dark-theme .action-card-cta{color:var(--oxide);}
19188 .action-card.view .action-card-cta{color:var(--accent-2);}
19189 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
19190 .action-card.compare .action-card-cta{color:#7c3aed;}
19191 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
19192 .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);}
19193 .action-card.git-tools .action-card-cta{color:#15803d;}
19194 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
19195 .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);}
19196 .action-card.trend .action-card-cta{color:#0e7490;}
19197 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
19198 .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);}
19199 .action-card.automation .action-card-cta{color:#b45309;}
19200 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
19201 .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);}
19202 .action-card.test-metrics .action-card-cta{color:#be185d;}
19203 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
19204 .action-card:hover .action-card-cta{gap:12px;}
19205 .action-card.card-split{flex-direction:row;align-items:stretch;}
19206 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
19207 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
19208 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
19209 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
19210 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
19211 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
19212 .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;}
19213 .ac-badge.active{opacity:1;}
19214 .ac-badge.github{border-color:#555;color:#555;}
19215 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
19216 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
19217 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
19218 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
19219 body.dark-theme .ac-right-row{color:var(--muted);}
19220 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
19221 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
19222 .divider{height:1px;background:var(--line);margin:32px 0;}
19223 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
19224 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
19225 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
19226 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
19227 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
19228 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
19229 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
19230 body.dark-theme .info-chip-val{color:var(--oxide);}
19231 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
19232 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
19233 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
19234 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
19235 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
19236 border:6px solid transparent;border-top-color:var(--text);}
19237 .info-chip:hover .info-chip-tip{display:block;}
19238 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
19239 .chip-slide.fading{filter:blur(5px);opacity:0;}
19240 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19241 .site-footer a{color:var(--muted);}
19242 .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;}
19243 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
19244 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
19245 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
19246 .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;}
19247 .lan-badge.local{background:var(--oxide-2);}
19248 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
19249 .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);}
19250 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
19251 .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;}
19252 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
19253 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
19254 .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;}
19255 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
19256 .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;}
19257 .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);}
19258 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
19259 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
19260 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
19261 .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;}
19262 @media (max-height: 1100px) {
19263 .page{padding-top:10px;}
19264 .hero{margin-bottom:10px;}
19265 .hero-logo{width:54px;height:60px;}
19266 .hero-logo-shadow{width:42px;}
19267 .hero-title{font-size:28px;}
19268 .hero-subtitle{font-size:13px;}
19269 .card-sections{gap:12px;margin-bottom:6px;}
19270 .card-section-grid-2,.card-section-grid-3{gap:10px;}
19271 .action-card{padding:8px 15px 8px;}
19272 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
19273 .action-card-icon svg{width:18px;height:18px;}
19274 .action-card-title{font-size:13px;}
19275 .action-card-desc{font-size:11px;margin-bottom:6px;}
19276 .action-card-cta{font-size:11px;}
19277 .ac-right-row{font-size:11px;}
19278 .divider{margin:14px 0;}
19279 .info-strip{gap:7px;margin-bottom:8px;}
19280 .info-chip{padding:7px 10px;}
19281 .info-chip-val{font-size:13px;}
19282 .info-chip-label{font-size:9px;}
19283 .site-footer{padding:8px 24px;font-size:12px;}
19284 .lan-local-hint{margin-top:8px;}
19285 }
19286 @media (max-height: 850px) {
19287 .page{padding-top:6px;}
19288 .hero{margin-bottom:6px;}
19289 .hero-logo{width:42px;height:46px;}
19290 .hero-title{font-size:22px;}
19291 .hero-subtitle{font-size:12px;}
19292 .card-sections{gap:10px;}
19293 .action-card-desc{margin-bottom:4px;}
19294 .divider{margin:8px 0;}
19295 .info-strip{margin-bottom:6px;}
19296 .lan-local-hint{margin-top:10px;}
19297 }
19298 </style>
19299</head>
19300<body>
19301 <div class="background-watermarks" aria-hidden="true">
19302 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19303 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19304 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19305 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19306 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19307 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19308 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19309 </div>
19310 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19311 <div class="top-nav">
19312 <div class="top-nav-inner">
19313 <a class="brand" href="/">
19314 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19315 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19316 </a>
19317 <div class="nav-right">
19318 <a class="nav-pill" href="/">Home</a>
19319 <div class="nav-dropdown">
19320 <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>
19321 <div class="nav-dropdown-menu">
19322 <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>
19323 </div>
19324 </div>
19325 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19326 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19327 <div class="nav-dropdown">
19328 <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>
19329 <div class="nav-dropdown-menu">
19330 <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>
19331 </div>
19332 </div>
19333 <div class="server-status-wrap" id="server-status-wrap">
19334 <div class="nav-pill server-online-pill" id="server-status-pill">
19335 <span class="status-dot" id="status-dot"></span>
19336 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19337 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19338 </div>
19339 <div class="server-status-tip">
19340 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
19341 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19342 </div>
19343 </div>
19344 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19345 <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>
19346 </button>
19347 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
19348 <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>
19349 <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>
19350 </button>
19351 </div>
19352 </div>
19353 </div>
19354
19355 <div class="page">
19356 <div class="hero">
19357 <div class="hero-logo-wrap" id="hero-logo-wrap">
19358 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
19359 </div>
19360 <div class="hero-logo-shadow"></div>
19361 <div class="hero-title-wrap">
19362 <div class="hero-title-aura" aria-hidden="true"></div>
19363 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
19364 </div>
19365 <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>
19366 </div>
19367
19368 <div class="card-sections">
19369
19370 <div>
19371 <div class="card-section-label">Analysis</div>
19372 <div class="card-section-grid-2">
19373 <a class="action-card scan card-split" href="/scan-setup">
19374 <div class="action-card-left">
19375 <div class="action-card-icon">
19376 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
19377 </div>
19378 <div class="action-card-title">Scan Project</div>
19379 <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>
19380 <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>
19381 </div>
19382 <div class="action-card-sep"></div>
19383 <div class="action-card-right">
19384 <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>
19385 <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>
19386 <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>
19387 <div class="ac-right-stat" id="acp-scan-stat"></div>
19388 </div>
19389 </a>
19390 <a class="action-card test-metrics card-split" href="/test-metrics">
19391 <div class="action-card-left">
19392 <div class="action-card-icon">
19393 <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>
19394 </div>
19395 <div class="action-card-title">Test Metrics</div>
19396 <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>
19397 <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>
19398 </div>
19399 <div class="action-card-sep"></div>
19400 <div class="action-card-right">
19401 <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>
19402 <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>
19403 <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>
19404 <div class="ac-right-stat" id="acp-test-stat"></div>
19405 </div>
19406 </a>
19407 </div>
19408 </div>
19409
19410 <div>
19411 <div class="card-section-label">Reports & Insights</div>
19412 <div class="card-section-grid-3">
19413 <a class="action-card view" href="/view-reports">
19414 <div class="action-card-icon">
19415 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
19416 </div>
19417 <div class="action-card-title">View Reports</div>
19418 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
19419 <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>
19420 </a>
19421 <a class="action-card compare" href="/compare-scans">
19422 <div class="action-card-icon">
19423 <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>
19424 </div>
19425 <div class="action-card-title">Compare Scans</div>
19426 <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>
19427 <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>
19428 </a>
19429 <a class="action-card trend" href="/trend-reports">
19430 <div class="action-card-icon">
19431 <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>
19432 </div>
19433 <div class="action-card-title">Trend Report</div>
19434 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
19435 <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>
19436 </a>
19437 </div>
19438 </div>
19439
19440 <div>
19441 <div class="card-section-label">Developer Tools</div>
19442 <div class="card-section-grid-2">
19443 <a class="action-card git-tools card-split" href="/git-browser">
19444 <div class="action-card-left">
19445 <div class="action-card-icon">
19446 <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>
19447 </div>
19448 <div class="action-card-title">Git Browser</div>
19449 <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>
19450 <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>
19451 </div>
19452 <div class="action-card-sep"></div>
19453 <div class="action-card-right">
19454 <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>
19455 <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>
19456 <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>
19457 </div>
19458 </a>
19459 <a class="action-card automation card-split" href="/integrations">
19460 <div class="action-card-left">
19461 <div class="action-card-icon">
19462 <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>
19463 </div>
19464 <div class="action-card-title">Integrations</div>
19465 <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>
19466 <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>
19467 </div>
19468 <div class="action-card-sep"></div>
19469 <div class="action-card-right">
19470 <div class="ac-badges-grid">
19471 <span class="ac-badge github" id="acp-gh">GitHub</span>
19472 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
19473 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
19474 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
19475 </div>
19476 <div class="ac-right-stat" id="acp-int-stat"></div>
19477 </div>
19478 </a>
19479 </div>
19480 </div>
19481
19482 </div>
19483
19484 {% if server_mode %}
19485 <div class="lan-card server">
19486 <div class="lan-card-header">
19487 <span class="lan-badge">LAN server</span>
19488 Accessible on your network
19489 </div>
19490 {% if let Some(ip) = lan_ip %}
19491 <div class="lan-url-row">
19492 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
19493 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
19494 <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>
19495 Copy URL
19496 </button>
19497 </div>
19498 <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>
19499 {% if has_api_key %}
19500 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
19501 {% endif %}
19502 {% else %}
19503 <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>
19504 {% endif %}
19505 </div>
19506 {% endif %}
19507
19508 <div class="divider"></div>
19509
19510 <div class="info-strip">
19511 <div class="info-chip">
19512 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
19513 <div class="chip-slide">
19514 <div class="info-chip-val">60</div>
19515 <div class="info-chip-label">Languages</div>
19516 </div>
19517 </div>
19518 <div class="info-chip">
19519 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
19520 <div class="chip-slide">
19521 <div class="info-chip-val">100%</div>
19522 <div class="info-chip-label">Self-contained</div>
19523 </div>
19524 </div>
19525 <div class="info-chip">
19526 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
19527 <div class="chip-slide">
19528 <div class="info-chip-val">HTML+PDF</div>
19529 <div class="info-chip-label">Exportable reports</div>
19530 </div>
19531 </div>
19532 <div class="info-chip">
19533 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
19534 <div class="chip-slide">
19535 <div class="info-chip-val">Webhook</div>
19536 <div class="info-chip-label">3 platforms</div>
19537 </div>
19538 </div>
19539 <div class="info-chip">
19540 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
19541 <div class="chip-slide">
19542 <div class="info-chip-val">IEEE</div>
19543 <div class="info-chip-label">1045-1992</div>
19544 </div>
19545 </div>
19546 </div>
19547
19548 {% if lan_ip.is_none() %}
19549 <div class="lan-local-hint">
19550 <strong>Want teammates on the same network to access this?</strong><br>
19551 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
19552 </div>
19553 {% endif %}
19554 </div>
19555
19556 <footer class="site-footer">
19557 local code analysis - metrics, history and reports
19558 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
19559 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19560 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19561 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19562 · <a href="/api-docs" rel="noopener">REST API</a>
19563 </footer>
19564
19565 <script nonce="{{ csp_nonce }}">
19566 (function () {
19567 var storageKey = 'oxide-sloc-theme';
19568 var body = document.body;
19569 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
19570 var toggle = document.getElementById('theme-toggle');
19571 if (toggle) toggle.addEventListener('click', function () {
19572 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
19573 body.classList.toggle('dark-theme', next === 'dark');
19574 try { localStorage.setItem(storageKey, next); } catch(e) {}
19575 });
19576 var copyBtn = document.getElementById('lan-copy-btn');
19577 if (copyBtn) copyBtn.addEventListener('click', function() {
19578 var btn = this;
19579 var el = document.getElementById('lan-url-val');
19580 if (!el) return;
19581 var url = el.textContent.trim();
19582 if (navigator.clipboard) {
19583 navigator.clipboard.writeText(url).then(function() {
19584 var orig = btn.innerHTML;
19585 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!';
19586 setTimeout(function() { btn.innerHTML = orig; }, 1800);
19587 });
19588 }
19589 });
19590 (function randomizeWatermarks() {
19591 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19592 if (!wms.length) return;
19593 var placed = [];
19594 function tooClose(top, left) {
19595 for (var i = 0; i < placed.length; i++) {
19596 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19597 if (dt < 16 && dl < 12) return true;
19598 }
19599 return false;
19600 }
19601 function pick(leftBand) {
19602 for (var attempt = 0; attempt < 50; attempt++) {
19603 var top = Math.random() * 88 + 2;
19604 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19605 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19606 }
19607 var top = Math.random() * 88 + 2;
19608 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19609 placed.push([top, left]); return [top, left];
19610 }
19611 var half = Math.floor(wms.length / 2);
19612 wms.forEach(function (img, i) {
19613 var pos = pick(i < half);
19614 var size = Math.floor(Math.random() * 100 + 120);
19615 var rot = (Math.random() * 360).toFixed(1);
19616 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19617 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;
19618 });
19619 })();
19620
19621 (function spawnCodeParticles() {
19622 var container = document.getElementById('code-particles');
19623 if (!container) return;
19624 var snippets = [
19625 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19626 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19627 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19628 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19629 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19630 ];
19631 var count = 38;
19632 for (var i = 0; i < count; i++) {
19633 (function(idx) {
19634 var el = document.createElement('span');
19635 el.className = 'code-particle';
19636 var text = snippets[idx % snippets.length];
19637 el.textContent = text;
19638 var left = Math.random() * 94 + 2;
19639 var top = Math.random() * 88 + 6;
19640 var dur = (Math.random() * 10 + 9).toFixed(1);
19641 var delay = (Math.random() * 18).toFixed(1);
19642 var rot = (Math.random() * 26 - 13).toFixed(1);
19643 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19644 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
19645 + '--rot:' + rot + 'deg;--op:' + op + ';'
19646 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
19647 container.appendChild(el);
19648 })(i);
19649 }
19650 })();
19651 (function heroAnimations() {
19652 var sub = document.getElementById('hero-subtitle');
19653 if (sub) {
19654 var full = sub.textContent.trim();
19655 sub.textContent = '';
19656 sub.style.opacity = '1';
19657 var cursor = document.createElement('span');
19658 cursor.className = 'hero-cursor';
19659 sub.appendChild(cursor);
19660 var i = 0;
19661 setTimeout(function() {
19662 var iv = setInterval(function() {
19663 if (i < full.length) {
19664 sub.insertBefore(document.createTextNode(full[i]), cursor);
19665 i++;
19666 } else {
19667 clearInterval(iv);
19668 setTimeout(function() {
19669 cursor.style.transition = 'opacity 1s ease';
19670 cursor.style.opacity = '0';
19671 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
19672 }, 2400);
19673 }
19674 }, 11);
19675 }, 374);
19676 }
19677 })();
19678 (function logoBob() {
19679 var logo = document.querySelector('.hero-logo');
19680 var shadow = document.querySelector('.hero-logo-shadow');
19681 if (!logo) return;
19682 var cycleStart = null, cycleDur = 3600;
19683 var peakY = -14, peakScale = 1.07, peakRot = 0;
19684 function newCycle() {
19685 cycleDur = 3000 + Math.random() * 1840;
19686 peakY = -(9 + Math.random() * 13.8);
19687 peakScale = 1.04 + Math.random() * 0.081;
19688 peakRot = (Math.random() * 11.5 - 5.75);
19689 }
19690 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
19691 newCycle();
19692 function frame(ts) {
19693 if (cycleStart === null) cycleStart = ts;
19694 var t = (ts - cycleStart) / cycleDur;
19695 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
19696 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
19697 var y = peakY * phase;
19698 var sc = 1 + (peakScale - 1) * phase;
19699 var rot = peakRot * Math.sin(Math.PI * phase);
19700 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
19701 if (shadow) {
19702 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
19703 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
19704 }
19705 requestAnimationFrame(frame);
19706 }
19707 requestAnimationFrame(frame);
19708 })();
19709 (function mouseEffects() {
19710 var heroTitle = document.getElementById('hero-title');
19711 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
19712 function tick() {
19713 raf = null;
19714 if (heroTitle) {
19715 var r = heroTitle.getBoundingClientRect();
19716 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
19717 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
19718 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
19719 }
19720 }
19721 document.addEventListener('mousemove', function(e) {
19722 mx = e.clientX; my = e.clientY;
19723 if (!raf) raf = requestAnimationFrame(tick);
19724 });
19725 document.addEventListener('mouseleave', function() {
19726 if (heroTitle) {
19727 heroTitle.style.transition = 'transform 0.5s ease';
19728 heroTitle.style.transform = '';
19729 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
19730 }
19731 });
19732 document.querySelectorAll('.action-card').forEach(function(card) {
19733 card.addEventListener('mousemove', function(e) {
19734 var rect = card.getBoundingClientRect();
19735 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
19736 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
19737 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
19738 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
19739 });
19740 card.addEventListener('mouseleave', function() {
19741 card.style.transition = '';
19742 card.style.transform = '';
19743 });
19744 });
19745 })();
19746 (function chipSlideshow() {
19747 var slides = [
19748 [{v:'60',l:'Languages'},{v:'Rust · Go · Python',l:'and 57 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
19749 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
19750 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
19751 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
19752 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
19753 ];
19754 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
19755 var indices = [0,0,0,0,0];
19756 var paused = [false,false,false,false,false];
19757 chips.forEach(function(chip, i) {
19758 chip.addEventListener('mouseenter', function() { paused[i] = true; });
19759 chip.addEventListener('mouseleave', function() { paused[i] = false; });
19760 });
19761 function advance(i) {
19762 if (paused[i]) return;
19763 var chip = chips[i];
19764 var inner = chip.querySelector('.chip-slide');
19765 if (!inner) return;
19766 inner.classList.add('fading');
19767 setTimeout(function() {
19768 indices[i] = (indices[i] + 1) % slides[i].length;
19769 var s = slides[i][indices[i]];
19770 chip.querySelector('.info-chip-val').textContent = s.v;
19771 chip.querySelector('.info-chip-label').textContent = s.l;
19772 inner.classList.remove('fading');
19773 }, 720);
19774 }
19775 setInterval(function() {
19776 chips.forEach(function(chip, i) { advance(i); });
19777 }, 6000);
19778 })();
19779 (function cardLiveData() {
19780 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
19781 var el = document.getElementById('acp-scan-stat');
19782 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
19783 }).catch(function(){});
19784 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
19785 var el = document.getElementById('acp-test-stat');
19786 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
19787 }).catch(function(){});
19788 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
19789 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
19790 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
19791 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
19792 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
19793 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
19794 var stat = document.getElementById('acp-int-stat');
19795 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
19796 }).catch(function(){});
19797 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
19798 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
19799 }).catch(function(){});
19800 })();
19801 })();
19802 </script>
19803 <script nonce="{{ csp_nonce }}">
19804 (function(){
19805 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'}];
19806 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);});}
19807 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19808 function init(){
19809 var btn=document.getElementById('settings-btn');if(!btn)return;
19810 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19811 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>';
19812 document.body.appendChild(m);
19813 var g=document.getElementById('scheme-grid');
19814 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);});
19815 var cl=document.getElementById('settings-close');
19816 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);
19817 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');});
19818 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19819 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19820 }
19821 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
19822 }());
19823 </script>
19824 <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>
19825</body>
19826</html>
19827"##,
19828 ext = "html"
19829)]
19830struct SplashTemplate {
19831 csp_nonce: String,
19832 server_mode: bool,
19833 lan_ip: Option<String>,
19834 port: u16,
19835 version: &'static str,
19836 has_api_key: bool,
19837}
19838
19839#[derive(Template)]
19842#[template(
19843 source = r##"
19844<!doctype html>
19845<html lang="en">
19846<head>
19847 <meta charset="utf-8">
19848 <meta name="viewport" content="width=device-width, initial-scale=1">
19849 <title>OxideSLOC — Start a Scan</title>
19850 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
19851 <style nonce="{{ csp_nonce }}">
19852 :root {
19853 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
19854 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
19855 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
19856 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
19857 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
19858 }
19859 body.dark-theme {
19860 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
19861 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
19862 }
19863 *{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;}
19864 .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);}
19865 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
19866 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
19867 .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));}
19868 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
19869 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
19870 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
19871 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
19872 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
19873 @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; } }
19874 .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;}
19875 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
19876 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
19877 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
19878 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
19879 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
19880 .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;}
19881 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
19882 .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);}
19883 .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;}
19884 .settings-close:hover{color:var(--text);background:var(--surface-2);}
19885 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
19886 .settings-modal-body{padding:14px 16px 16px;}
19887 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
19888 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
19889 .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;}
19890 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
19891 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
19892 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
19893 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
19894 .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;}
19895 .tz-select:focus{border-color:var(--oxide);}
19896 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
19897 .page-header{text-align:center;margin-bottom:16px;}
19898 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
19899 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
19900 /* Cards */
19901 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
19902 .option-card-wrap{position:relative;}
19903 .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;}
19904 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
19905 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
19906 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
19907 .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;}
19908 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
19909 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
19910 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
19911 .card-top-row{display:flex;align-items:center;gap:20px;}
19912 /* Two-column layout inside each card */
19913 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
19914 .card-left{display:flex;align-items:flex-start;min-width:0;}
19915 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
19916 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
19917 .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);}
19918 .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);}
19919 .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);}
19920 .card-text{min-width:0;}
19921 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
19922 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
19923 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
19924 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
19925 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
19926 /* Right CTA column */
19927 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
19928 .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;}
19929 /* Re-scan count badge */
19930 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
19931 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
19932 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
19933 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
19934 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
19935 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
19936 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
19937 body.dark-theme .btn-secondary{color:var(--oxide);}
19938 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
19939 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
19940 /* File input overlay — must be full-width so it aligns with other card-right buttons */
19941 .file-input-wrap{position:relative;width:100%;}
19942 .file-input-wrap .btn{width:100%;}
19943 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
19944 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19945 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
19946 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
19947 .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;}
19948 @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));}}
19949 /* Recent list (card 3 — full-width section below header) */
19950 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
19951 .recent-list{display:flex;flex-direction:column;gap:8px;}
19952 .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;}
19953 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
19954 .recent-item-info{flex:1;min-width:0;}
19955 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
19956 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
19957 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
19958 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
19959 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
19960 .site-footer a{color:var(--muted);}
19961 @media(max-width:680px){
19962 .card-body{grid-template-columns:1fr;}
19963 .card-right{flex-direction:row;flex-wrap:wrap;}
19964 .btn{flex:1;}
19965 }
19966 .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;}
19967 .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;}
19968 .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;}
19969 </style>
19970</head>
19971<body>
19972 <div class="background-watermarks" aria-hidden="true">
19973 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19974 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19975 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19976 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19977 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19978 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19979 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
19980 </div>
19981 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
19982 <div class="top-nav">
19983 <div class="top-nav-inner">
19984 <a class="brand" href="/">
19985 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
19986 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
19987 </a>
19988 <div class="nav-right">
19989 <a class="nav-pill" href="/">Home</a>
19990 <div class="nav-dropdown">
19991 <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>
19992 <div class="nav-dropdown-menu">
19993 <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>
19994 </div>
19995 </div>
19996 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
19997 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
19998 <div class="nav-dropdown">
19999 <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>
20000 <div class="nav-dropdown-menu">
20001 <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>
20002 </div>
20003 </div>
20004 <div class="server-status-wrap" id="server-status-wrap">
20005 <div class="nav-pill server-online-pill" id="server-status-pill">
20006 <span class="status-dot" id="status-dot"></span>
20007 <span id="server-status-label">Server</span>
20008 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20009 </div>
20010 <div class="server-status-tip">
20011 OxideSLOC is running — accessible on your network.
20012 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20013 </div>
20014 </div>
20015 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20016 <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>
20017 </button>
20018 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
20019 <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>
20020 <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>
20021 </button>
20022 </div>
20023 </div>
20024 </div>
20025
20026 <div class="page">
20027 <div class="page-header">
20028 <h1>How would you like to scan?</h1>
20029 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
20030 </div>
20031
20032 <div class="option-grid">
20033
20034 <!-- Option 1: New scan -->
20035 <div class="option-card-wrap">
20036 <div class="option-card">
20037 <div class="option-icon new-scan">
20038 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
20039 </div>
20040 <div class="card-body">
20041 <div class="card-left">
20042 <div class="card-text">
20043 <div class="option-title">Start a new scan</div>
20044 <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>
20045 <ul class="feature-list">
20046 <li>Live project scope preview before you run</li>
20047 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
20048 <li>HTML, PDF, and JSON output — your choice</li>
20049 </ul>
20050 </div>
20051 </div>
20052 <div class="card-right">
20053 <a class="btn btn-primary" href="/scan">
20054 Configure & scan
20055 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
20056 </a>
20057 <p class="card-tip">Full 4-step setup · all options</p>
20058 </div>
20059 </div>
20060 </div>
20061 </div>
20062
20063 <!-- Option 2: Load from config file -->
20064 <div class="option-card-wrap">
20065 <div class="option-card">
20066 <div class="option-icon load-config">
20067 <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>
20068 </div>
20069 <div class="card-body">
20070 <div class="card-left">
20071 <div class="card-text">
20072 <div class="option-title">Load a saved config</div>
20073 <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>
20074 <ul class="feature-list">
20075 <li>All 15 settings restored from the file</li>
20076 <li>Fully editable — change path or output dir</li>
20077 <li>Works with any scan-config.json</li>
20078 </ul>
20079 </div>
20080 </div>
20081 <div class="card-right">
20082 <div class="file-input-wrap">
20083 <button class="btn btn-secondary" id="load-config-btn" type="button">
20084 <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>
20085 Choose config file
20086 </button>
20087 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
20088 </div>
20089 <p class="card-tip" id="config-file-name">Exported after every scan</p>
20090 </div>
20091 </div>
20092 </div>
20093 </div>
20094
20095 <!-- Option 3: Re-scan recent project -->
20096 <div class="option-card-wrap">
20097 <div class="option-card" id="recent-card">
20098 <div class="card-top-row">
20099 <div class="option-icon rescan">
20100 <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>
20101 </div>
20102 <div class="card-body">
20103 <div class="card-left">
20104 <div class="card-text">
20105 <div class="option-title">Re-scan a recent project</div>
20106 <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>
20107 <ul class="feature-list">
20108 <li>All 15+ settings restored from the saved config</li>
20109 <li>Path and output dir are editable before running</li>
20110 <li>Only scans with a saved config appear here</li>
20111 </ul>
20112 </div>
20113 </div>
20114 <div class="card-right">
20115 <div class="rescan-count-box">
20116 <div class="rescan-count-num" id="rescan-count-num">—</div>
20117 <div class="rescan-count-label">saved configs</div>
20118 </div>
20119 <a class="btn btn-secondary" href="/view-reports">
20120 <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>
20121 View all runs
20122 </a>
20123 <p class="card-tip">Opens run history</p>
20124 </div>
20125 </div>
20126 </div>
20127 <div class="section-divider"></div>
20128 <div class="recent-list" id="recent-list">
20129 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
20130 </div>
20131 </div>
20132 </div>
20133
20134 </div>
20135 </div>
20136
20137 <footer class="site-footer">
20138 local code analysis - metrics, history and reports
20139 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
20140 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
20141 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
20142 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
20143 · <a href="/api-docs" rel="noopener">REST API</a>
20144 </footer>
20145
20146 <script nonce="{{ csp_nonce }}">
20147 (function () {
20148 var storageKey = 'oxide-sloc-theme';
20149 var body = document.body;
20150 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
20151 var toggle = document.getElementById('theme-toggle');
20152 if (toggle) toggle.addEventListener('click', function () {
20153 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
20154 body.classList.toggle('dark-theme', next === 'dark');
20155 try { localStorage.setItem(storageKey, next); } catch(e) {}
20156 });
20157
20158 (function randomizeWatermarks() {
20159 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
20160 if (!wms.length) return;
20161 var placed = [];
20162 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; }
20163 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]; }
20164 var half = Math.floor(wms.length / 2);
20165 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; });
20166 })();
20167 (function spawnCodeParticles() {
20168 var container = document.getElementById('code-particles');
20169 if (!container) return;
20170 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'];
20171 var count = 38;
20172 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); }
20173 })();
20174 // Recent scans data injected from server
20175 var recentScans = {{ recent_scans_json|safe }};
20176
20177 function configToParams(cfg) {
20178 var p = new URLSearchParams();
20179 p.set('prefilled', '1');
20180 if (cfg.path) p.set('path', cfg.path);
20181 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
20182 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
20183 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
20184 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
20185 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
20186 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
20187 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
20188 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
20189 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
20190 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
20191 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
20192 if (cfg.report_title) p.set('report_title', cfg.report_title);
20193 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
20194 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
20195 return p;
20196 }
20197
20198 // Build recent scan list (capped at 3 visible entries)
20199 var list = document.getElementById('recent-list');
20200 var noNote = document.getElementById('no-recent-note');
20201 var hasAny = false;
20202 var MAX_RECENT = 3;
20203 if (Array.isArray(recentScans)) {
20204 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
20205 var shown = 0;
20206 validEntries.forEach(function (entry) {
20207 if (shown >= MAX_RECENT) return;
20208 shown++;
20209 hasAny = true;
20210 var item = document.createElement('div');
20211 item.className = 'recent-item';
20212 item.title = 'Restore all settings and open wizard';
20213 item.innerHTML =
20214 '<div class="recent-item-info">' +
20215 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
20216 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
20217 '</div>' +
20218 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
20219 item.addEventListener('click', function () {
20220 var params = configToParams(entry.config);
20221 window.location.href = '/scan?' + params.toString();
20222 });
20223 list.appendChild(item);
20224 });
20225 if (validEntries.length > MAX_RECENT) {
20226 var moreEl = document.createElement('div');
20227 moreEl.className = 'recent-more-link';
20228 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
20229 list.appendChild(moreEl);
20230 }
20231 }
20232 if (hasAny && noNote) noNote.style.display = 'none';
20233 // Update count badge
20234 var countEl = document.getElementById('rescan-count-num');
20235 if (countEl) {
20236 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
20237 countEl.textContent = total > 0 ? total : '0';
20238 }
20239
20240 // Config file loader
20241 var fileInput = document.getElementById('config-file-input');
20242 var fileName = document.getElementById('config-file-name');
20243 var loadBtn = document.getElementById('load-config-btn');
20244 // Wire the visible button to open the hidden file picker.
20245 if (loadBtn && fileInput) {
20246 loadBtn.addEventListener('click', function () { fileInput.click(); });
20247 }
20248 if (fileInput) {
20249 fileInput.addEventListener('change', function () {
20250 var file = fileInput.files && fileInput.files[0];
20251 if (!file) return;
20252 if (fileName) fileName.textContent = '✓ ' + file.name;
20253 var reader = new FileReader();
20254 reader.onload = function (e) {
20255 try {
20256 var cfg = JSON.parse(e.target.result);
20257 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
20258 var params = configToParams(cfg);
20259 window.location.href = '/scan?' + params.toString();
20260 } catch (err) {
20261 alert('Could not parse config file: ' + err.message);
20262 }
20263 };
20264 reader.readAsText(file);
20265 });
20266 }
20267
20268 function escHtml(s) {
20269 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
20270 }
20271 })();
20272 </script>
20273 <script nonce="{{ csp_nonce }}">
20274 (function(){
20275 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'}];
20276 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);});}
20277 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
20278 function init(){
20279 var btn=document.getElementById('settings-btn');if(!btn)return;
20280 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
20281 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>';
20282 document.body.appendChild(m);
20283 var g=document.getElementById('scheme-grid');
20284 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);});
20285 var cl=document.getElementById('settings-close');
20286 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);
20287 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');});
20288 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
20289 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
20290 }
20291 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
20292 }());
20293 </script>
20294 <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]';
20295 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;}
20296 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>
20297</body>
20298</html>
20299"##,
20300 ext = "html"
20301)]
20302struct ScanSetupTemplate {
20303 version: &'static str,
20304 recent_scans_json: String,
20305 csp_nonce: String,
20306}
20307
20308#[derive(Template)]
20309#[template(
20310 source = r##"
20311<!doctype html>
20312<html lang="en">
20313<head>
20314 <meta charset="utf-8">
20315 <meta name="viewport" content="width=device-width, initial-scale=1">
20316 <title>OxideSLOC | {{ report_title }} | Report</title>
20317 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
20318 <style nonce="{{ csp_nonce }}">
20319 :root {
20320 --radius: 18px;
20321 --bg: #f5efe8;
20322 --surface: rgba(255,255,255,0.82);
20323 --surface-2: #fbf7f2;
20324 --surface-3: #efe6dc;
20325 --line: #e6d0bf;
20326 --line-strong: #dcb89f;
20327 --text: #43342d;
20328 --muted: #7b675b;
20329 --muted-2: #a08777;
20330 --nav: #b85d33;
20331 --nav-2: #7a371b;
20332 --accent: #6f9bff;
20333 --accent-2: #4a78ee;
20334 --oxide: #d37a4c;
20335 --oxide-2: #b35428;
20336 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
20337 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
20338 --success-bg: #e8f5ed;
20339 --success-text: #1a8f47;
20340 --info-bg: #eef3ff;
20341 --info-text: #4467d8;
20342 }
20343
20344 body.dark-theme {
20345 --bg: #1b1511;
20346 --surface: #261c17;
20347 --surface-2: #2d221d;
20348 --surface-3: #372922;
20349 --line: #524238;
20350 --line-strong: #6c5649;
20351 --text: #f5ece6;
20352 --muted: #c7b7aa;
20353 --muted-2: #aa9485;
20354 --nav: #b85d33;
20355 --nav-2: #7a371b;
20356 --accent: #6f9bff;
20357 --accent-2: #4a78ee;
20358 --oxide: #d37a4c;
20359 --oxide-2: #b35428;
20360 --shadow: 0 18px 42px rgba(0,0,0,0.28);
20361 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
20362 --success-bg: #163927;
20363 --success-text: #8fe2a8;
20364 --info-bg: #1c2847;
20365 --info-text: #a9c1ff;
20366 }
20367
20368 * { box-sizing: border-box; }
20369 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); }
20370 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
20371 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
20372 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
20373 .top-nav, .page { position: relative; z-index: 2; }
20374 .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); }
20375 .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; }
20376 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
20377 .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)); }
20378 .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; }
20379 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
20380 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
20381 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
20382 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
20383 .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; }
20384 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
20385 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20386 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
20387 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
20388 @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; } }
20389 .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; }
20390 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
20391 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
20392 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
20393 .theme-toggle .icon-sun { display:none; }
20394 body.dark-theme .theme-toggle .icon-sun { display:block; }
20395 body.dark-theme .theme-toggle .icon-moon { display:none; }
20396 .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;}
20397 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
20398 .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);}
20399 .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;}
20400 .settings-close:hover{color:var(--text);background:var(--surface-2);}
20401 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
20402 .settings-modal-body{padding:14px 16px 16px;}
20403 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
20404 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
20405 .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;}
20406 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
20407 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
20408 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
20409 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
20410 .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;}
20411 .tz-select:focus{border-color:var(--oxide);}
20412 .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; }
20413 .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;}
20414 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
20415 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
20416 .hero, .panel { padding: 22px; }
20417 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
20418 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
20419 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
20420 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
20421 .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; }
20422 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
20423 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
20424 .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; }
20425 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
20426 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
20427 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
20428 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
20429 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
20430 .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
20431 .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; }
20432 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
20433 .delta-card-val { font-size:16px; font-weight:800; }
20434 .delta-card-val.pos { color:#1e7e34; }
20435 .delta-card-val.neg { color:var(--neg); }
20436 .delta-card-val.mod { color:#b35428; }
20437 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
20438 .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; }
20439 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20440 .delta-card-inline:hover .delta-card-tip { opacity:1; }
20441 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
20442 .compare-ts { font-size:13px; color:var(--muted); }
20443 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
20444 .compare-arrow { color: var(--muted); }
20445 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
20446 .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; }
20447 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
20448 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
20449 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
20450 .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; }
20451 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
20452 .run-mgmt-card .action-buttons { justify-content:center; }
20453 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
20454 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
20455 .button, .copy-button {
20456 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;
20457 }
20458 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
20459 @keyframes spin { to { transform: rotate(360deg); } }
20460 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
20461 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
20462 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
20463 .path-item strong { display: block; margin-bottom: 6px; }
20464 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
20465 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
20466 .path-subitem { flex: 1; }
20467 .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); }
20468 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); }
20469 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
20470 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
20471 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
20472 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
20473 th { color: var(--muted); font-weight: 700; }
20474 tr:last-child td { border-bottom: none; }
20475 #subm-tbl col:nth-child(1){width:15%;}
20476 #subm-tbl col:nth-child(2){width:31%;}
20477 #subm-tbl col:nth-child(3){width:9%;}
20478 #subm-tbl col:nth-child(4){width:9%;}
20479 #subm-tbl col:nth-child(5){width:9%;}
20480 #subm-tbl col:nth-child(6){width:9%;}
20481 #subm-tbl col:nth-child(7){width:9%;}
20482 #subm-tbl col:nth-child(8){width:9%;}
20483 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
20484 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
20485 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
20486 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
20487 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
20488 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
20489 .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; }
20490 .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; }
20491 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
20492 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
20493 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
20494 .muted { color: var(--muted); }
20495 /* Run-ID chip row (mirrors HTML report) */
20496 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
20497 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
20498 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
20499 .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; }
20500 .run-id-chip[data-copy] { cursor:pointer; }
20501 a.run-id-chip { text-decoration:none; cursor:pointer; }
20502 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
20503 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
20504 .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; }
20505 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
20506 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
20507 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
20508 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
20509 a.commit-link-value { color:inherit; text-decoration:none; }
20510 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
20511 .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; }
20512 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20513 .run-id-chip:hover .chip-tooltip { opacity:1; }
20514 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
20515 .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; }
20516 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
20517 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
20518 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
20519 /* Meta chips row */
20520 .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%; }
20521 .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; }
20522 .meta-chip:last-child { border-right:none; }
20523 .meta-chip b { color:var(--text); font-weight:700; }
20524 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
20525 .site-footer a{color:var(--muted);}
20526 .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; }
20527 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
20528 .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; }
20529 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
20530 /* Stat chips (matches HTML report) */
20531 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
20532 @media(max-width:1200px){.summary-strip{grid-template-columns:repeat(4,1fr);}}
20533 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
20534 .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; }
20535 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
20536 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
20537 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
20538 .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; }
20539 .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); }
20540 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
20541 .stat-chip:hover .stat-chip-tip { opacity:1; }
20542 .cocomo-box { background:var(--surface-2); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
20543 .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; }
20544 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
20545 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
20546 .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); }
20547 .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); }
20548 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
20549 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; }
20550 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
20551 /* Submodule panel */
20552 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
20553 /* Metrics tables stack */
20554 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
20555 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
20556 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
20557 .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)); }
20558 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
20559 /* Metrics table */
20560 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
20561 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
20562 .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; }
20563 .metrics-table thead th:not(:first-child) { text-align: right; }
20564 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
20565 .metrics-table tbody tr:last-child td { border-bottom: none; }
20566 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
20567 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
20568 .metrics-table tbody tr:hover td { background: var(--surface-2); }
20569 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
20570 .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; }
20571 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
20572 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
20573 .mt-val-pos { color: var(--pos); font-weight: 700; }
20574 .mt-val-neg { color: var(--neg); font-weight: 700; }
20575 .mt-val-zero { color: var(--muted); }
20576 .mt-val-mod { color: var(--oxide-2); }
20577 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
20578 @media (max-width: 1180px) {
20579 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
20580 .nav-project-slot, .nav-status { justify-content:flex-start; }
20581 .hero-top { flex-direction: column; }
20582 .run-mgmt-strip { flex-direction: column; }
20583 }
20584 .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;}
20585 @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));}}
20586 .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;}
20587 /* ── Result-page chart controls ─────────────────────────────────────────── */
20588 .r-chart-section{margin-bottom:24px;}
20589 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
20590 .section-pair > .panel{flex-shrink:0;}
20591 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
20592 .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;}
20593 .r-chart-select:focus{border-color:var(--accent);}
20594 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
20595 .r-chart-container svg{display:block;width:100%;height:auto;}
20596 .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;}
20597 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
20598 .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;}
20599 .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);}
20600 .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;}
20601 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
20602 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
20603 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
20604 .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;}
20605 .r-chart-modal-close:hover{opacity:.7;}
20606 body.dark-theme .r-chart-modal{background:var(--surface);}
20607 .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;}
20608 .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);}
20609 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
20610 .lang-bar-row:hover{transform:translateY(-2px);}
20611 .lang-bar-row .rchit:hover{filter:none;transform:none;}
20612 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
20613 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
20614 .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;}
20615 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
20616 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
20617 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
20618 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
20619 #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;}
20620 .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
20621 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
20622 .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;}
20623 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
20624 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
20625 .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;}
20626 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
20627 .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;}
20628 .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;}
20629 body.has-report-banner .top-nav{top:27px;}
20630 body.has-report-banner{padding-bottom:27px;}
20631 </style>
20632</head>
20633<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
20634 <div class="background-watermarks" aria-hidden="true">
20635 <img src="/images/logo/logo-text.png" alt="" />
20636 <img src="/images/logo/logo-text.png" alt="" />
20637 <img src="/images/logo/logo-text.png" alt="" />
20638 <img src="/images/logo/logo-text.png" alt="" />
20639 <img src="/images/logo/logo-text.png" alt="" />
20640 <img src="/images/logo/logo-text.png" alt="" />
20641 <img src="/images/logo/logo-text.png" alt="" />
20642 <img src="/images/logo/logo-text.png" alt="" />
20643 <img src="/images/logo/logo-text.png" alt="" />
20644 <img src="/images/logo/logo-text.png" alt="" />
20645 <img src="/images/logo/logo-text.png" alt="" />
20646 <img src="/images/logo/logo-text.png" alt="" />
20647 <img src="/images/logo/logo-text.png" alt="" />
20648 <img src="/images/logo/logo-text.png" alt="" />
20649 </div>
20650 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
20651 {% if let Some(banner) = report_header_footer %}
20652 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
20653 {% endif %}
20654 <div class="top-nav">
20655 <div class="top-nav-inner">
20656 <a class="brand" href="/">
20657 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
20658 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
20659 </a>
20660 <div class="nav-project-slot">
20661 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
20662 </div>
20663 <div class="nav-status">
20664 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
20665 <div class="nav-dropdown">
20666 <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>
20667 <div class="nav-dropdown-menu">
20668 <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>
20669 </div>
20670 </div>
20671 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
20672 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
20673 <div class="nav-dropdown">
20674 <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>
20675 <div class="nav-dropdown-menu">
20676 <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>
20677 </div>
20678 </div>
20679 <div class="server-status-wrap" id="server-status-wrap">
20680 <div class="nav-pill server-online-pill" id="server-status-pill">
20681 <span class="status-dot" id="status-dot"></span>
20682 <span id="server-status-label">Server</span>
20683 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
20684 </div>
20685 <div class="server-status-tip">
20686 OxideSLOC is running — accessible on your network.
20687 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
20688 </div>
20689 </div>
20690 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
20691 <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>
20692 </button>
20693 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
20694 <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>
20695 <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>
20696 </button>
20697 </div>
20698 </div>
20699 </div>
20700
20701 <div class="page">
20702 <section class="hero">
20703 <div class="hero-top">
20704 <div>
20705 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
20706 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
20707 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
20708 <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>
20709 </div>
20710 </div>
20711 <div class="hero-quick-actions">
20712 {% if server_mode %}
20713 <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>
20714 {% else %}
20715 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
20716 {% endif %}
20717 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
20718 {% if !server_mode %}
20719 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
20720 {% endif %}
20721 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
20722 <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>
20723 </div>
20724 </div>
20725
20726 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
20727 <div class="run-id-row">
20728 <span class="run-id-chip" data-copy="{{ run_id }}">
20729 <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>
20730 <span class="run-id-chip-value">{{ run_id }}</span>
20731 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
20732 </span>
20733 {% match git_commit_long %}
20734 {% when Some with (long_sha) %}
20735 {% match git_commit_url %}
20736 {% when Some with (commit_url) %}
20737 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
20738 <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>
20739 <span class="run-id-chip-value">{{ long_sha }}</span>
20740 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
20741 </a>
20742 {% when None %}
20743 <span class="run-id-chip" data-copy="{{ long_sha }}">
20744 <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>
20745 <span class="run-id-chip-value">{{ long_sha }}</span>
20746 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
20747 </span>
20748 {% endmatch %}
20749 {% when None %}
20750 <span class="run-id-chip muted-chip">
20751 <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>
20752 <span class="run-id-chip-value">Not detected</span>
20753 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
20754 </span>
20755 {% endmatch %}
20756 {% match git_branch %}
20757 {% when Some with (branch) %}
20758 {% match git_branch_url %}
20759 {% when Some with (branch_url) %}
20760 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
20761 <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>
20762 <span class="run-id-chip-value">{{ branch }}</span>
20763 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
20764 </a>
20765 {% when None %}
20766 <span class="run-id-chip">
20767 <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>
20768 <span class="run-id-chip-value">{{ branch }}</span>
20769 <span class="chip-tooltip">Git branch active at scan time</span>
20770 </span>
20771 {% endmatch %}
20772 {% when None %}
20773 <span class="run-id-chip muted-chip">
20774 <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>
20775 <span class="run-id-chip-value">Not detected</span>
20776 <span class="chip-tooltip">No Git branch was found for this scan</span>
20777 </span>
20778 {% endmatch %}
20779 {% match git_author %}
20780 {% when Some with (author) %}
20781 <span class="run-id-chip" data-author="{{ author }}">
20782 <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>
20783 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
20784 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
20785 </span>
20786 {% when None %}
20787 <span class="run-id-chip muted-chip">
20788 <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>
20789 <span class="run-id-chip-value">Not detected</span>
20790 <span class="chip-tooltip">No commit author was found for this scan</span>
20791 </span>
20792 {% endmatch %}
20793 </div>
20794
20795 <!-- Scan metadata row -->
20796 <div class="meta">
20797 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
20798 <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
20799 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
20800 <span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
20801 <span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
20802 </div>
20803
20804 <!-- All summary stat chips in one unified strip (8 columns) -->
20805 <div class="summary-strip">
20806 <div class="stat-chip" data-raw="{{ physical_lines }}">
20807 <div class="stat-chip-label">Physical lines</div>
20808 <div class="stat-chip-val">{{ physical_lines }}</div>
20809 <div class="stat-chip-exact"></div>
20810 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
20811 </div>
20812 <div class="stat-chip" data-raw="{{ code_lines }}">
20813 <div class="stat-chip-label">Code</div>
20814 <div class="stat-chip-val">{{ code_lines }}</div>
20815 <div class="stat-chip-exact"></div>
20816 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
20817 </div>
20818 <div class="stat-chip" data-raw="{{ comment_lines }}">
20819 <div class="stat-chip-label">Comments</div>
20820 <div class="stat-chip-val">{{ comment_lines }}</div>
20821 <div class="stat-chip-exact"></div>
20822 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
20823 </div>
20824 <div class="stat-chip" data-raw="{{ blank_lines }}">
20825 <div class="stat-chip-label">Blank</div>
20826 <div class="stat-chip-val">{{ blank_lines }}</div>
20827 <div class="stat-chip-exact"></div>
20828 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
20829 </div>
20830 <div class="stat-chip" data-raw="{{ mixed_lines }}">
20831 <div class="stat-chip-label">Mixed separate</div>
20832 <div class="stat-chip-val">{{ mixed_lines }}</div>
20833 <div class="stat-chip-exact"></div>
20834 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
20835 </div>
20836 <div class="stat-chip" data-raw="{{ functions }}">
20837 <div class="stat-chip-label">Functions</div>
20838 <div class="stat-chip-val">{{ functions }}</div>
20839 <div class="stat-chip-exact"></div>
20840 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
20841 </div>
20842 <div class="stat-chip" data-raw="{{ classes }}">
20843 <div class="stat-chip-label">Classes / Types</div>
20844 <div class="stat-chip-val">{{ classes }}</div>
20845 <div class="stat-chip-exact"></div>
20846 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
20847 </div>
20848 <div class="stat-chip" data-raw="{{ variables }}">
20849 <div class="stat-chip-label">Variables</div>
20850 <div class="stat-chip-val">{{ variables }}</div>
20851 <div class="stat-chip-exact"></div>
20852 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
20853 </div>
20854 <div class="stat-chip" data-raw="{{ imports }}">
20855 <div class="stat-chip-label">Imports</div>
20856 <div class="stat-chip-val">{{ imports }}</div>
20857 <div class="stat-chip-exact"></div>
20858 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
20859 </div>
20860 <div class="stat-chip" data-raw="{{ test_count }}">
20861 <div class="stat-chip-label">Tests</div>
20862 <div class="stat-chip-val">{{ test_count }}</div>
20863 <div class="stat-chip-exact"></div>
20864 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
20865 </div>
20866 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
20867 <div class="stat-chip-label">Code density</div>
20868 <div class="stat-chip-val stat-chip-density-val">—</div>
20869 <div class="stat-chip-exact"></div>
20870 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
20871 </div>
20872 <div class="stat-chip" data-raw="{{ files_analyzed }}">
20873 <div class="stat-chip-label">Files analyzed</div>
20874 <div class="stat-chip-val">{{ files_analyzed }}</div>
20875 <div class="stat-chip-exact"></div>
20876 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
20877 </div>
20878 {% if cyclomatic_complexity > 0 %}
20879 <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 %}>
20880 <div class="stat-chip-label">Complexity score</div>
20881 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
20882 <div class="stat-chip-exact"></div>
20883 <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>
20884 </div>
20885 {% endif %}
20886 {% if let Some(ls) = lsloc %}
20887 <div class="stat-chip" data-raw="{{ ls }}">
20888 <div class="stat-chip-label">Logical SLOC</div>
20889 <div class="stat-chip-val">{{ ls }}</div>
20890 <div class="stat-chip-exact"></div>
20891 <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>
20892 </div>
20893 {% endif %}
20894 {% if uloc > 0 %}
20895 <div class="stat-chip" data-raw="{{ uloc }}">
20896 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
20897 <div class="stat-chip-val">{{ uloc }}</div>
20898 <div class="stat-chip-exact"></div>
20899 <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>
20900 </div>
20901 {% endif %}
20902 {% if uloc > 0 && dryness_pct_str != "" %}
20903 <div class="stat-chip">
20904 <div class="stat-chip-label">DRYness</div>
20905 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
20906 <div class="stat-chip-exact"></div>
20907 <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>
20908 </div>
20909 {% endif %}
20910 {% if duplicate_group_count > 0 %}
20911 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
20912 <div class="stat-chip-label">Duplicate groups</div>
20913 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
20914 <div class="stat-chip-exact"></div>
20915 <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>
20916 </div>
20917 {% endif %}
20918 </div>
20919
20920 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
20921 <div class="compare-banner">
20922 <div class="compare-banner-body">
20923 <div class="compare-banner-top">
20924 <div class="compare-banner-meta">
20925 <span class="compare-label">Previous scan</span>
20926 <span class="compare-ts">{{ prev_ts }}</span>
20927 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
20928 {% if let Some(prev_code) = prev_run_code_lines %}
20929 <div class="compare-banner-stats" style="margin-top:4px;">
20930 <span>Code before: <strong>{{ prev_code }}</strong></span>
20931 <span class="compare-arrow">→</span>
20932 <span>Code now: <strong>{{ code_lines }}</strong></span>
20933 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
20934 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
20935 </div>
20936 {% endif %}
20937 </div>
20938 {% if delta_lines_added.is_some() %}
20939 <div class="delta-cards-inline">
20940 <div class="delta-card-inline">
20941 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
20942 <div class="delta-card-lbl">lines added</div>
20943 <div class="delta-card-tip">Code lines added since the previous scan</div>
20944 </div>
20945 <div class="delta-card-inline">
20946 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
20947 <div class="delta-card-lbl">lines removed</div>
20948 <div class="delta-card-tip">Code lines removed since the previous scan</div>
20949 </div>
20950 <div class="delta-card-inline">
20951 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
20952 <div class="delta-card-lbl">unmodified lines</div>
20953 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
20954 </div>
20955 <div class="delta-card-inline">
20956 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
20957 <div class="delta-card-lbl">files modified</div>
20958 <div class="delta-card-tip">Files with at least one line changed</div>
20959 </div>
20960 <div class="delta-card-inline">
20961 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
20962 <div class="delta-card-lbl">files added</div>
20963 <div class="delta-card-tip">New files added since the previous scan</div>
20964 </div>
20965 <div class="delta-card-inline">
20966 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
20967 <div class="delta-card-lbl">files removed</div>
20968 <div class="delta-card-tip">Files deleted since the previous scan</div>
20969 </div>
20970 <div class="delta-card-inline">
20971 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
20972 <div class="delta-card-lbl">files unchanged</div>
20973 <div class="delta-card-tip">Files with no changes since the previous scan</div>
20974 </div>
20975 </div>
20976 {% else %}
20977 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
20978 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
20979 </p>
20980 {% endif %}
20981 </div>
20982 <div class="compare-banner-actions">
20983 <div class="compare-banner-actions-left">
20984 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
20985 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
20986 </div>
20987 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
20988 </div>
20989 </div>
20990 </div>
20991 {% endif %}{% endif %}
20992
20993 <div class="action-grid">
20994 <div class="action-card">
20995 <h3>HTML report</h3>
20996 <div class="action-buttons">
20997 {% match html_url %}
20998 {% when Some with (url) %}
20999 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
21000 {% when None %}{% endmatch %}
21001 {% match html_download_url %}
21002 {% when Some with (url) %}
21003 <a class="button secondary" href="{{ url }}">Download HTML</a>
21004 {% when None %}{% endmatch %}
21005 {% match html_path %}
21006 {% when Some with (_path) %}{% when None %}{% endmatch %}
21007 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
21008 </div>
21009 </div>
21010 <div class="action-card">
21011 <h3>PDF report</h3>
21012 <div class="action-buttons">
21013 {% match pdf_url %}
21014 {% when Some with (url) %}
21015 {% if pdf_generating %}
21016 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
21017 <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>
21018 Generating PDF…
21019 </button>
21020 {% else %}
21021 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
21022 {% endif %}
21023 {% when None %}
21024 {% match html_url %}
21025 {% when Some with (_hurl) %}
21026 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
21027 <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>
21028 {% when None %}
21029 <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;">
21030 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
21031 </p>
21032 {% endmatch %}
21033 {% endmatch %}
21034 {% match pdf_download_url %}
21035 {% when Some with (url) %}
21036 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
21037 {% when None %}{% endmatch %}
21038 {% match pdf_url %}
21039 {% when Some with (_) %}
21040 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
21041 {% when None %}{% endmatch %}
21042 </div>
21043 </div>
21044 <div class="action-card">
21045 <h3>JSON result</h3>
21046 <div class="action-buttons">
21047 {% match json_url %}
21048 {% when Some with (url) %}
21049 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
21050 {% when None %}{% endmatch %}
21051 {% match json_download_url %}
21052 {% when Some with (url) %}
21053 <a class="button secondary" href="{{ url }}">Download JSON</a>
21054 {% when None %}{% endmatch %}
21055 {% match json_path %}
21056 {% when Some with (_path) %}
21057 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
21058 {% when None %}
21059 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
21060 {% endmatch %}
21061 </div>
21062 </div>
21063 <div class="action-card">
21064 <h3>Scan config</h3>
21065 <div class="action-buttons">
21066 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
21067 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
21068 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
21069 </div>
21070 </div>
21071 {% if confluence_configured %}
21072 <div class="action-card" id="confluenceCard">
21073 <h3>Confluence</h3>
21074 <div class="action-buttons">
21075 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
21076 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
21077 </div>
21078 <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>
21079 </div>
21080 {% endif %}
21081 </div>
21082 {% if confluence_configured %}
21083 <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;">
21084 <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);">
21085 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
21086 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
21087 <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;">
21088 <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>
21089 <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;">
21090 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
21091 <div style="display:flex;gap:10px;justify-content:flex-end;">
21092 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
21093 <button class="button" id="confSubmitBtn" type="button">Post</button>
21094 </div>
21095 </div>
21096 </div>
21097 {% endif %}
21098 <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;">
21099 <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);">
21100 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
21101 <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>
21102 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
21103 <div style="display:flex;gap:18px;justify-content:flex-end;">
21104 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
21105 <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>
21106 </div>
21107 </div>
21108 </div>
21109 {% if !submodule_rows.is_empty() %}
21110 <div class="submodule-panel">
21111 <div class="toolbar-row">
21112 <div>
21113 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
21114 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
21115 </div>
21116 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
21117 </div>
21118 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
21119 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
21120 <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>
21121 <thead>
21122 <tr>
21123 <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>
21124 <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>
21125 <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>
21126 <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>
21127 <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>
21128 <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>
21129 <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>
21130 <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>
21131 </tr>
21132 </thead>
21133 <tbody>
21134 {% for row in submodule_rows %}
21135 <tr>
21136 <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>
21137 <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>
21138 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
21139 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
21140 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
21141 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
21142 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
21143 <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>
21144 </tr>
21145 {% endfor %}
21146 </tbody>
21147 </table>
21148 </div>
21149 </div>
21150 {% endif %}
21151
21152 <div class="metrics-tables-stack">
21153
21154 <div class="metrics-table-wrap">
21155 <div class="metrics-table-title">Files</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>Files analyzed</td>
21168 <td class="mt-val-large">{{ files_analyzed }}</td>
21169 <td>{{ prev_fa_str }}</td>
21170 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
21171 </tr>
21172 <tr>
21173 <td>Files skipped</td>
21174 <td>{{ files_skipped }}</td>
21175 <td>{{ prev_fs_str }}</td>
21176 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
21177 </tr>
21178 <tr>
21179 <td>Files modified</td>
21180 <td class="mt-val-na">—</td>
21181 <td class="mt-val-na">—</td>
21182 <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>
21183 </tr>
21184 <tr>
21185 <td>Files unchanged</td>
21186 <td class="mt-val-na">—</td>
21187 <td class="mt-val-na">—</td>
21188 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
21189 </tr>
21190 </tbody>
21191 </table>
21192 </div>
21193
21194 <div class="metrics-table-wrap">
21195 <div class="metrics-table-title">Line Counts</div>
21196 <table class="metrics-table">
21197 <thead>
21198 <tr>
21199 <th>Metric</th>
21200 <th>This Run</th>
21201 <th>Previous</th>
21202 <th>Change</th>
21203 </tr>
21204 </thead>
21205 <tbody>
21206 <tr>
21207 <td>Physical lines</td>
21208 <td class="mt-val-large">{{ physical_lines }}</td>
21209 <td>{{ prev_pl_str }}</td>
21210 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
21211 </tr>
21212 <tr>
21213 <td>Code lines</td>
21214 <td class="mt-val-large">{{ code_lines }}</td>
21215 <td>{{ prev_cl_str }}</td>
21216 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
21217 </tr>
21218 <tr>
21219 <td>Comment lines</td>
21220 <td>{{ comment_lines }}</td>
21221 <td>{{ prev_cml_str }}</td>
21222 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
21223 </tr>
21224 <tr>
21225 <td>Blank lines</td>
21226 <td>{{ blank_lines }}</td>
21227 <td>{{ prev_bl_str }}</td>
21228 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
21229 </tr>
21230 <tr>
21231 <td>Mixed (separate)</td>
21232 <td>{{ mixed_lines }}</td>
21233 <td class="mt-val-na">—</td>
21234 <td class="mt-val-na">—</td>
21235 </tr>
21236 </tbody>
21237 </table>
21238 </div>
21239
21240 <div class="metrics-tables-lower">
21241 <div class="metrics-table-wrap">
21242 <div class="metrics-table-title">Code Structure</div>
21243 <table class="metrics-table">
21244 <thead>
21245 <tr>
21246 <th>Metric</th>
21247 <th>This Run</th>
21248 </tr>
21249 </thead>
21250 <tbody>
21251 <tr>
21252 <td>Functions</td>
21253 <td>{{ functions }}</td>
21254 </tr>
21255 <tr>
21256 <td>Classes / Types</td>
21257 <td>{{ classes }}</td>
21258 </tr>
21259 <tr>
21260 <td>Variables</td>
21261 <td>{{ variables }}</td>
21262 </tr>
21263 <tr>
21264 <td>Imports</td>
21265 <td>{{ imports }}</td>
21266 </tr>
21267 </tbody>
21268 </table>
21269 </div>
21270
21271 <div class="metrics-table-wrap">
21272 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
21273 <table class="metrics-table">
21274 <thead>
21275 <tr>
21276 <th>Metric</th>
21277 <th>Change</th>
21278 </tr>
21279 </thead>
21280 <tbody>
21281 <tr>
21282 <td>Lines added</td>
21283 <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>
21284 </tr>
21285 <tr>
21286 <td>Lines removed</td>
21287 <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>
21288 </tr>
21289 <tr>
21290 <td>Lines modified (net)</td>
21291 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
21292 </tr>
21293 <tr>
21294 <td>Lines unmodified</td>
21295 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
21296 </tr>
21297 </tbody>
21298 </table>
21299 </div>
21300 </div>
21301
21302 </div>
21303
21304 <div class="path-list">
21305 <div class="path-item">
21306 <div class="path-item-label">Project path</div>
21307 <code>{{ project_path }}</code>
21308 </div>
21309 <div class="path-item">
21310 <div class="path-item-label">Git branch</div>
21311 {% if let Some(branch) = git_branch %}
21312 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
21313 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
21314 {% else %}
21315 <code style="color:var(--muted)">—</code>
21316 {% endif %}
21317 </div>
21318 <div class="path-item">
21319 <div class="path-item-label">Output folder</div>
21320 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
21321 </div>
21322 <div class="path-item">
21323 <div class="path-item-label">Run ID</div>
21324 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
21325 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
21326 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
21327 </div>
21328 </div>
21329 </div>
21330 </section>
21331
21332 {% if has_cocomo %}
21333 <div class="cocomo-box" style="margin-top:24px;">
21334 <div class="cocomo-box-head">
21335 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
21336 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
21337 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
21338 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
21339 </span>
21340 </div>
21341 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
21342 <div class="stat-chip">
21343 <div class="stat-chip-label">Person-months</div>
21344 <div class="stat-chip-val">{{ cocomo_effort_str }}</div>
21345 <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>
21346 </div>
21347 <div class="stat-chip">
21348 <div class="stat-chip-label">Schedule (months)</div>
21349 <div class="stat-chip-val">{{ cocomo_duration_str }}</div>
21350 <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>
21351 </div>
21352 <div class="stat-chip">
21353 <div class="stat-chip-label">Avg. Team Size</div>
21354 <div class="stat-chip-val">{{ cocomo_staff_str }}</div>
21355 <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>
21356 </div>
21357 <div class="stat-chip">
21358 <div class="stat-chip-label">Input KSLOC</div>
21359 <div class="stat-chip-val">{{ cocomo_ksloc_str }}K</div>
21360 <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>
21361 </div>
21362 </div>
21363 <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>
21364 </div>
21365 {% endif %}
21366
21367 <div class="section-pair">
21368 <section class="panel">
21369 <div class="toolbar-row">
21370 <div>
21371 <h2>Language breakdown</h2>
21372 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
21373 </div>
21374 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21375 </div>
21376 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
21377 </section>
21378
21379 <section class="panel r-chart-section">
21380 <div class="toolbar-row" style="margin-bottom:16px;">
21381 <div>
21382 <h2>Visualizations</h2>
21383 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
21384 </div>
21385 </div>
21386
21387 <div class="r-viz-grid">
21388 <div class="r-viz-card">
21389 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21390 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
21391 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21392 </div>
21393 <div class="r-chart-tab-bar">
21394 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
21395 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
21396 </div>
21397 <div class="r-chart-container" id="r-composition-chart"></div>
21398 </div>
21399 <div class="r-viz-card">
21400 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21401 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
21402 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21403 </div>
21404 <div class="r-chart-container" id="r-scatter-chart"></div>
21405 </div>
21406 {% if has_semantic_data %}
21407 <div class="r-viz-card">
21408 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
21409 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
21410 <select class="r-chart-select" id="r-semantic-metric">
21411 <option value="functions">Functions</option>
21412 <option value="classes">Classes</option>
21413 <option value="variables">Variables</option>
21414 <option value="imports">Imports</option>
21415 <option value="tests">Tests</option>
21416 </select>
21417 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21418 </div>
21419 <div class="r-chart-container" id="r-semantic-chart"></div>
21420 </div>
21421 {% endif %}
21422 <div class="r-viz-card">
21423 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21424 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
21425 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21426 </div>
21427 <div class="r-chart-container" id="r-density-chart"></div>
21428 </div>
21429 <div class="r-viz-card">
21430 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
21431 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
21432 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21433 </div>
21434 <div class="r-chart-container" id="r-avglines-chart"></div>
21435 </div>
21436 <div class="r-viz-card">
21437 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
21438 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
21439 <select class="r-chart-select" id="r-sub-metric">
21440 <option value="code">Code Lines</option>
21441 <option value="comment">Comments</option>
21442 <option value="blank">Blank Lines</option>
21443 <option value="physical">Physical Lines</option>
21444 <option value="files">Files</option>
21445 </select>
21446 <select class="r-chart-select" id="r-sub-sort">
21447 <option value="desc">Value ↓</option>
21448 <option value="asc">Value ↑</option>
21449 <option value="name">Name A→Z</option>
21450 </select>
21451 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
21452 </div>
21453 <div class="r-chart-container" id="r-submodule-chart"></div>
21454 </div>
21455 </div>
21456
21457 </section>
21458 </div>
21459
21460 </div>
21461
21462 <div id="r-tt" aria-hidden="true"></div>
21463
21464 <script nonce="{{ csp_nonce }}">
21465 (function () {
21466 var body = document.body;
21467 var themeToggle = document.getElementById('theme-toggle');
21468 var storageKey = 'oxide-sloc-theme';
21469
21470 function applyTheme(theme) {
21471 body.classList.toggle('dark-theme', theme === 'dark');
21472 }
21473
21474 function loadSavedTheme() {
21475 try {
21476 var saved = localStorage.getItem(storageKey);
21477 if (saved === 'dark' || saved === 'light') {
21478 applyTheme(saved);
21479 }
21480 } catch (e) {}
21481 }
21482
21483 if (themeToggle) {
21484 themeToggle.addEventListener('click', function () {
21485 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
21486 applyTheme(nextTheme);
21487 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
21488 });
21489 }
21490
21491 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
21492 button.addEventListener('click', function () {
21493 var value = button.getAttribute('data-copy-value') || '';
21494 if (!value) return;
21495 var originalText = button.textContent;
21496 function flashSuccess() {
21497 button.textContent = 'Copied!';
21498 setTimeout(function () { button.textContent = originalText; }, 1800);
21499 }
21500 function flashFail() {
21501 button.textContent = 'Copy failed';
21502 setTimeout(function () { button.textContent = originalText; }, 2000);
21503 }
21504 if (navigator.clipboard && navigator.clipboard.writeText) {
21505 navigator.clipboard.writeText(value).then(flashSuccess, function () {
21506 fallbackCopy(value, flashSuccess, flashFail);
21507 });
21508 } else {
21509 fallbackCopy(value, flashSuccess, flashFail);
21510 }
21511 });
21512 });
21513 function fallbackCopy(text, onSuccess, onFail) {
21514 try {
21515 var ta = document.createElement('textarea');
21516 ta.value = text;
21517 ta.style.position = 'fixed';
21518 ta.style.top = '-9999px';
21519 ta.style.left = '-9999px';
21520 document.body.appendChild(ta);
21521 ta.focus();
21522 ta.select();
21523 var ok = document.execCommand('copy');
21524 document.body.removeChild(ta);
21525 if (ok) { onSuccess(); } else { onFail(); }
21526 } catch (e) { onFail(); }
21527 }
21528
21529 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21530 btn.addEventListener('click', function () {
21531 var folder = btn.getAttribute('data-folder') || '';
21532 if (!folder) return;
21533 var orig = btn.textContent;
21534 fetch('/open-path?path=' + encodeURIComponent(folder))
21535 .then(function (r) { return r.json(); })
21536 .then(function (d) {
21537 if (d && d.server_mode_disabled) {
21538 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21539 } else if (d && d.ok) {
21540 btn.textContent = 'Opened!';
21541 setTimeout(function () { btn.textContent = orig; }, 1800);
21542 }
21543 })
21544 .catch(function () {
21545 btn.textContent = 'Failed';
21546 setTimeout(function () { btn.textContent = orig; }, 2000);
21547 });
21548 });
21549 });
21550
21551 loadSavedTheme();
21552
21553 // ── Compact number formatting for stat chips ──────────────────────────
21554 (function(){
21555 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();}
21556 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
21557 var raw=parseInt(chip.getAttribute('data-raw'),10);
21558 if(isNaN(raw))return;
21559 var valEl=chip.querySelector('.stat-chip-val');
21560 if(valEl)valEl.textContent=fmt(raw);
21561 var exactEl=chip.querySelector('.stat-chip-exact');
21562 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
21563 });
21564 // Code density chip
21565 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
21566 var code=parseInt(chip.getAttribute('data-code'),10);
21567 var phys=parseInt(chip.getAttribute('data-physical'),10);
21568 if(isNaN(code)||isNaN(phys)||phys===0)return;
21569 var pct=(code/phys*100).toFixed(1)+'%';
21570 var valEl=chip.querySelector('.stat-chip-val');
21571 if(valEl)valEl.textContent=pct;
21572 });
21573 // Populate author handle from data-author attribute
21574 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
21575 var author=chip.getAttribute('data-author');
21576 var el=chip.querySelector('.author-handle');
21577 if(el)el.textContent='/'+author.replace(/\s+/g,'');
21578 });
21579 // Click-to-copy on run-id-chip elements
21580 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
21581 chip.addEventListener('click',function(){
21582 var val=chip.getAttribute('data-copy');
21583 if(!val)return;
21584 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
21585 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);}
21586 chip.classList.add('chip-copied-flash');
21587 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
21588 });
21589 });
21590 })();
21591
21592 // ── Shared tooltip for all result-page charts ─────────────────────────
21593 var rTT=(function(){
21594 var el=document.getElementById('r-tt');
21595 if(!el)return{s:function(){},h:function(){},m:function(){}};
21596 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
21597 function hide(){el.style.display='none';}
21598 function move(e){
21599 var x=e.clientX+16,y=e.clientY-12;
21600 var r=el.getBoundingClientRect();
21601 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
21602 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
21603 el.style.left=x+'px';el.style.top=y+'px';
21604 }
21605 return{s:show,h:hide,m:move};
21606 })();
21607 window.rTT=rTT;
21608
21609 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
21610 (function(){
21611 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21612 document.addEventListener('mouseover',function(e){
21613 var t=e.target;
21614 while(t&&t.getAttribute){
21615 var l=t.getAttribute('data-ttl');
21616 if(l!==null){
21617 var v=t.getAttribute('data-ttv')||'';
21618 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
21619 return;
21620 }
21621 t=t.parentNode;
21622 }
21623 });
21624 document.addEventListener('mouseout',function(e){
21625 var t=e.target;
21626 while(t&&t.getAttribute){
21627 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
21628 t=t.parentNode;
21629 }
21630 });
21631 document.addEventListener('mousemove',function(e){
21632 var el=document.getElementById('r-tt');
21633 if(el&&el.style.display!=='none')rTT.m(e);
21634 });
21635 window.addEventListener('blur',function(){rTT.h();});
21636 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
21637 })();
21638
21639 // ── Language overview charts ───────────────────────────────────────────
21640 (function(){
21641 var D={{ lang_chart_json|safe }};
21642 if(!D||!D.length)return;
21643 var el=document.getElementById('result-lang-charts');
21644 if(!el)return;
21645 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21646 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
21647 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21648 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();}
21649 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21650 function px(n){return Math.round(n);}
21651 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+'"';}
21652 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
21653
21654 // Donut chart — height matches the stacked-bar chart so both panels align
21655 var rHb_d=28;
21656 var DH=Math.max(220,D.length*rHb_d+32);
21657 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
21658 var legX=204,DW=360;
21659 var legCount=D.length;
21660 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
21661 var legYStart=Math.round((DH-legCount*legSpacing)/2);
21662 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">';
21663 if(D.length===1){
21664 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
21665 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+'"/>';
21666 } else {
21667 var ang=-Math.PI/2;
21668 D.forEach(function(d,i){
21669 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
21670 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
21671 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
21672 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
21673 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
21674 var pct=Math.round(d.code/tot*100);
21675 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"/>';
21676 ang+=sw;
21677 });
21678 }
21679 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
21680 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
21681 D.forEach(function(d,i){
21682 var ly=legYStart+i*legSpacing;
21683 var pctL=Math.round(d.code/tot*100);
21684 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
21685 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
21686 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
21687 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
21688 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
21689 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
21690 ds+='</g>';
21691 });
21692 ds+='</svg>';
21693
21694 // Horizontal stacked-bar chart — fills container width
21695 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
21696 var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
21697 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">';
21698 D.forEach(function(d,i){
21699 var y=6+i*rHb,x=LW;
21700 var phys=d.physical||d.code+d.comments+d.blanks;
21701 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
21702 bs+='<g class="lang-bar-row">';
21703 bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
21704 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>';
21705 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;
21706 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;
21707 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"/>';
21708 bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
21709 bs+='</g>';
21710 });
21711 var ly=SH-14;
21712 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
21713 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
21714 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
21715 var totAll=totC+totCm+totBl||1;
21716 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
21717 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
21718 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
21719 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
21720 bs+='<g data-kind="code" style="cursor:pointer;">'
21721 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
21722 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
21723 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
21724 +'</g>';
21725 bs+='<g data-kind="comment" style="cursor:pointer;">'
21726 +'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
21727 +'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
21728 +'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
21729 +'</g>';
21730 bs+='<g data-kind="blank" style="cursor:pointer;">'
21731 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
21732 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
21733 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
21734 +'</g>';
21735 bs+='</svg>';
21736 el.innerHTML='<div class="r-lang-overview">'+
21737 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
21738 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
21739 '</div>';
21740 function wireDonutLegend(svg){
21741 if(!svg)return;
21742 var paths=svg.querySelectorAll('path[data-lang]');
21743 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';}}}
21744 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
21745 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;}});
21746 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
21747 }
21748 function wireMixLegend(svg){
21749 if(!svg)return;
21750 var legGs=svg.querySelectorAll('g[data-kind]');
21751 var allRects=svg.querySelectorAll('rect[data-kind]');
21752 if(!legGs.length)return;
21753 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';}}
21754 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='';}}
21755 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]);}
21756 }
21757 wireDonutLegend(el.querySelector('svg'));
21758 wireMixLegend(el.querySelectorAll('svg')[1]);
21759
21760 // ── Language breakdown Full View expand ─────────────────────────────────
21761 var langOvBtn=document.getElementById('result-lang-overview-expand');
21762 if(langOvBtn){langOvBtn.addEventListener('click',function(){
21763 var src=document.getElementById('result-lang-charts');
21764 if(!src)return;
21765 var overlay=document.createElement('div');
21766 overlay.className='r-chart-modal-overlay';
21767 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>';
21768 document.body.appendChild(overlay);
21769 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21770 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21771 var wrap=document.getElementById('result-lang-overview-modal-wrap');
21772 if(wrap){
21773 wrap.innerHTML=src.innerHTML;
21774 var svgs=wrap.querySelectorAll('svg');
21775 for(var i=0;i<svgs.length;i++){
21776 svgs[i].removeAttribute('width');
21777 svgs[i].removeAttribute('height');
21778 svgs[i].style.cssText='display:block;width:100%;height:auto;';
21779 }
21780 var ov=wrap.querySelector('.r-lang-overview');
21781 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
21782 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
21783 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
21784 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
21785 wireDonutLegend(wrap.querySelector('svg'));
21786 wireMixLegend(wrap.querySelectorAll('svg')[1]);
21787 requestAnimationFrame(function(){
21788 var ss=wrap.querySelectorAll('svg');
21789 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%;';}}
21790 });
21791 }
21792 });}
21793 })();
21794
21795 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
21796 (function(){
21797 var LANG_D={{ lang_chart_json|safe }};
21798 var SCAT_D={{ scatter_chart_json|safe }};
21799 var SEM_D={{ semantic_chart_json|safe }};
21800 var SUB_D={{ submodule_chart_json|safe }};
21801 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
21802 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
21803 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();}
21804 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
21805 function px(n){return Math.round(n);}
21806 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+'"';}
21807
21808 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
21809 function renderCompositionInEl(el,mode,shOvr){
21810 if(!el||!LANG_D||!LANG_D.length)return;
21811 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
21812 var LW=110,SH=shOvr||224;
21813 var svgW=Math.max(320,el.offsetWidth||480);
21814 var BW=Math.max(120,svgW-LW-80);
21815 var legendH=24,topPad=4;
21816 var n=LANG_D.length||1;
21817 var rowTotal=Math.floor((SH-legendH-topPad)/n);
21818 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
21819 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">';
21820 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
21821 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
21822 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
21823 var totAll2=totC2+totCm2+totBl2||1;
21824 if(mode==='pct'){
21825 LANG_D.forEach(function(d,i){
21826 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
21827 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
21828 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21829 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>';
21830 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;
21831 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;
21832 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+'"/>';
21833 var pct=Math.round((d.code||0)/tot2*100);
21834 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>';
21835 });
21836 } else {
21837 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
21838 LANG_D.forEach(function(d,i){
21839 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
21840 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
21841 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>';
21842 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;
21843 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;
21844 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+'"/>';
21845 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>';
21846 });
21847 }
21848 var ly=SH-legendH+4;
21849 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
21850 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
21851 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
21852 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
21853 s+='<g data-kind="code" style="cursor:pointer;">'
21854 +'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
21855 +'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
21856 +'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
21857 +'</g>';
21858 s+='<g data-kind="comment" style="cursor:pointer;">'
21859 +'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
21860 +'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
21861 +'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
21862 +'</g>';
21863 s+='<g data-kind="blank" style="cursor:pointer;">'
21864 +'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
21865 +'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
21866 +'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
21867 +'</g>';
21868 s+='</svg>';
21869 el.innerHTML=s;
21870 wireMixLegendEl(el);
21871 }
21872 function wireMixLegendEl(container){
21873 var svg=container&&container.querySelector('svg');
21874 if(!svg)return;
21875 var legGs=svg.querySelectorAll('g[data-kind]');
21876 var allRects=svg.querySelectorAll('rect[data-kind]');
21877 if(!legGs.length)return;
21878 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';}}
21879 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='';}}
21880 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]);}
21881 }
21882 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
21883 renderComposition('abs');
21884 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
21885 btn.addEventListener('click',function(){
21886 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
21887 btn.classList.add('active');
21888 renderComposition(btn.getAttribute('data-rcomp'));
21889 });
21890 });
21891
21892 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
21893 function renderScatterInEl(el,hOvr){
21894 if(!el||!SCAT_D||!SCAT_D.length)return;
21895 var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
21896 var W=Math.max(320,el.offsetWidth||480);
21897 var cW=W-PL-PR,cH=H-PT-PB;
21898 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
21899 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
21900 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
21901 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">';
21902 [0,0.25,0.5,0.75,1].forEach(function(t){
21903 var y=PT+cH*(1-t);
21904 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21905 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>';
21906 });
21907 [0,0.25,0.5,0.75,1].forEach(function(t){
21908 var x=PL+cW*t;
21909 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
21910 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>';
21911 });
21912 SCAT_D.forEach(function(d,i){
21913 var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
21914 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
21915 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"/>';
21916 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>';
21917 });
21918 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>';
21919 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>';
21920 s+='</svg>';
21921 el.innerHTML=s;
21922 }
21923 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
21924
21925 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
21926 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
21927 // the old vertical column layout on wide containers.
21928 function renderSemanticInEl(el,key,sh){
21929 if(!el||!SEM_D||!SEM_D.length)return;
21930 var n2=SEM_D.length||1;
21931 var LW=112,SH=sh||Math.max(180,n2*28+26);
21932 var svgW=Math.max(320,el.offsetWidth||480);
21933 var BW=Math.max(120,svgW-LW-80);
21934 var topPad=4,botPad=14;
21935 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
21936 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
21937 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
21938 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">';
21939 SEM_D.forEach(function(d,i){
21940 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
21941 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>';
21942 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"/>';
21943 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>';
21944 });
21945 s+='</svg>';
21946 el.innerHTML=s;
21947 }
21948 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
21949 var semSel=document.getElementById('r-semantic-metric');
21950 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
21951 var semExpand=document.getElementById('r-semantic-expand');
21952 if(semExpand){
21953 semExpand.addEventListener('click',function(){
21954 var key=semSel?semSel.value:'functions';
21955 var n=SEM_D.length||1;
21956 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
21957 var modalH=Math.min(Math.max(360,n*38+60),maxH);
21958 var overlay=document.createElement('div');
21959 overlay.className='r-chart-modal-overlay';
21960 var optHtml=
21961 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
21962 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
21963 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
21964 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
21965 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
21966 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>';
21967 document.body.appendChild(overlay);
21968 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21969 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21970 var modalEl=document.getElementById('r-sem-modal-chart');
21971 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
21972 var modalSel=document.getElementById('r-sem-modal-metric');
21973 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
21974 });
21975 }
21976
21977 // ── Expand buttons: re-render charts at large size inside modal ──────────
21978 (function(){
21979 function makeExpandModal(title,mH,subtitle,ctrlHtml){
21980 var overlay=document.createElement('div');
21981 overlay.className='r-chart-modal-overlay';
21982 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
21983 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
21984 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>';
21985 document.body.appendChild(overlay);
21986 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
21987 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
21988 return overlay.querySelector('.r-expand-modal-chart');
21989 }
21990 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
21991 var compExpandBtn=document.getElementById('r-composition-expand');
21992 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
21993 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
21994 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
21995 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
21996 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
21997 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
21998 if(wrap){
21999 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
22000 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
22001 btn.addEventListener('click',function(){
22002 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
22003 btn.classList.add('active');
22004 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
22005 });
22006 });
22007 }
22008 });}
22009 var scatExpandBtn=document.getElementById('r-scatter-expand');
22010 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
22011 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
22012 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
22013 });}
22014 var densExpandBtn=document.getElementById('r-density-expand');
22015 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
22016 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
22017 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
22018 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
22019 });}
22020 var avgExpandBtn=document.getElementById('r-avglines-expand');
22021 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
22022 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
22023 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
22024 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
22025 });}
22026 var subExpandBtn=document.getElementById('r-submodule-expand');
22027 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
22028 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
22029 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
22030 var metCtrl=
22031 '<select class="r-chart-select" id="r-sub-modal-metric">'
22032 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
22033 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
22034 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
22035 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
22036 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
22037 +'</select>';
22038 var sortCtrl=
22039 '<select class="r-chart-select" id="r-sub-modal-sort">'
22040 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
22041 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
22042 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
22043 +'</select>';
22044 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
22045 if(wrap){
22046 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
22047 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
22048 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
22049 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
22050 if(mSub)mSub.addEventListener('change',reRenderSub);
22051 if(mSort)mSort.addEventListener('change',reRenderSub);
22052 }
22053 });}
22054 })();
22055
22056 // ── Comment Density: comments / (code + comments) per language ───────────
22057 function renderDensityInEl(el,shOvr){
22058 if(!el||!LANG_D||!LANG_D.length)return;
22059 var n=LANG_D.length||1;
22060 var LW=112,SH=shOvr||Math.max(180,n*28+26);
22061 var svgW=Math.max(320,el.offsetWidth||480);
22062 var BW=Math.max(120,svgW-LW-80);
22063 var topPad=4,botPad=26;
22064 var rowTotal=Math.floor((SH-topPad-botPad)/n);
22065 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22066 var densities=LANG_D.map(function(d){
22067 var sig=(d.code||0)+(d.comments||0);
22068 return sig>0?(d.comments||0)/sig:0;
22069 });
22070 var maxDen=Math.max.apply(null,densities)||1;
22071 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">';
22072 LANG_D.forEach(function(d,i){
22073 var den=densities[i],bw=den/maxDen*BW;
22074 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22075 var pct=Math.round(den*100);
22076 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>';
22077 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"/>';
22078 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22079 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>';
22080 });
22081 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>';
22082 s+='</svg>';
22083 el.innerHTML=s;
22084 }
22085 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
22086 renderDensity();
22087
22088 // ── Avg Lines per File: code / files per language ─────────────────────
22089 function renderAvgLinesInEl(el,shOvr){
22090 if(!el||!LANG_D||!LANG_D.length)return;
22091 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
22092 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
22093 var n=data.length||1;
22094 var LW=112,SH=shOvr||Math.max(180,n*28+26);
22095 var svgW=Math.max(320,el.offsetWidth||480);
22096 var BW=Math.max(120,svgW-LW-80);
22097 var topPad=4,botPad=26;
22098 var rowTotal=Math.floor((SH-topPad-botPad)/n);
22099 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
22100 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
22101 var maxAvg=Math.max.apply(null,avgs)||1;
22102 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">';
22103 data.forEach(function(d,i){
22104 var avg=avgs[i],bw=avg/maxAvg*BW;
22105 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
22106 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>';
22107 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"/>';
22108 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22109 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>';
22110 });
22111 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>';
22112 s+='</svg>';
22113 el.innerHTML=s;
22114 }
22115 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
22116 renderAvgLines();
22117
22118 // ── Repository Overview: overall row + per-submodule rows ────────────
22119 function renderSubmoduleInEl(el,key,sort,shOvr){
22120 if(!el)return;
22121 var overall={
22122 name:'Overall',
22123 code:{{ code_lines }},
22124 comment:{{ comment_lines }},
22125 blank:{{ blank_lines }},
22126 physical:{{ physical_lines }},
22127 files:{{ files_analyzed }},
22128 isOverall:true
22129 };
22130 var subs=SUB_D.slice();
22131 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
22132 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
22133 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
22134 var data=[overall].concat(subs);
22135 var rowH=32,bH=22,sepH=subs.length>0?14:0;
22136 var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
22137 var svgW=Math.max(320,el.offsetWidth||480);
22138 var LW=116,BW=Math.max(200,svgW-LW-54);
22139 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
22140 var OVERALL_COL='#6b7280';
22141 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">';
22142 var yOff=4;
22143 data.forEach(function(d,i){
22144 var v=d[key]||0,bw=v/maxV*BW,y=yOff;
22145 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
22146 var label=d.name||d.path||'?';
22147 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>';
22148 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
22149 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
22150 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>';
22151 yOff+=rowH;
22152 if(d.isOverall&&subs.length>0){
22153 yOff+=sepH;
22154 }
22155 });
22156 s+='</svg>';
22157 el.innerHTML=s;
22158 }
22159 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
22160 var subSel=document.getElementById('r-sub-metric');
22161 var sortSel=document.getElementById('r-sub-sort');
22162 renderSubmodule('code','desc');
22163 if(subSel){
22164 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
22165 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
22166 }
22167
22168 // Equalise heights within each chart row: if one chart in a grid row is taller
22169 // than its neighbour, re-render the shorter one at the taller height so bars fill
22170 // the available vertical space instead of leaving a gap.
22171 function syncRowHeights(){
22172 var avgEl=document.getElementById('r-avglines-chart');
22173 var subEl=document.getElementById('r-submodule-chart');
22174 if(avgEl&&subEl){
22175 var avgSvg=avgEl.querySelector('svg');
22176 var subSvg=subEl.querySelector('svg');
22177 if(avgSvg&&subSvg){
22178 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
22179 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
22180 var key=subSel?subSel.value||'code':'code';
22181 var sort=sortSel?sortSel.value:'desc';
22182 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
22183 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
22184 }
22185 }
22186 var semEl=document.getElementById('r-semantic-chart');
22187 var denEl=document.getElementById('r-density-chart');
22188 if(semEl&&denEl){
22189 var semSvg=semEl.querySelector('svg');
22190 var denSvg=denEl.querySelector('svg');
22191 if(semSvg&&denSvg){
22192 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
22193 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
22194 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
22195 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
22196 }
22197 }
22198 }
22199 syncRowHeights();
22200
22201 // Re-render all SVG charts when the window is resized so bars fill the card.
22202 var _rResizeTimer;
22203 window.addEventListener('resize',function(){
22204 clearTimeout(_rResizeTimer);
22205 _rResizeTimer=setTimeout(function(){
22206 var rcompBtn=document.querySelector('[data-rcomp].active');
22207 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
22208 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
22209 if(semSel)renderSemantic(semSel.value||'functions');
22210 renderDensity();
22211 renderAvgLines();
22212 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
22213 syncRowHeights();
22214 },120);
22215 });
22216 })();
22217
22218 (function randomizeWatermarks() {
22219 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22220 if (!wms.length) return;
22221 var placed = [];
22222 function tooClose(top, left) {
22223 for (var i = 0; i < placed.length; i++) {
22224 var dt = Math.abs(placed[i][0] - top);
22225 var dl = Math.abs(placed[i][1] - left);
22226 if (dt < 20 && dl < 18) return true;
22227 }
22228 return false;
22229 }
22230 function pick(leftBand) {
22231 for (var attempt = 0; attempt < 50; attempt++) {
22232 var top = Math.random() * 85 + 5;
22233 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22234 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22235 }
22236 var top = Math.random() * 85 + 5;
22237 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
22238 placed.push([top, left]);
22239 return [top, left];
22240 }
22241 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
22242 var half = Math.floor(wms.length / 2);
22243 wms.forEach(function (img, i) {
22244 var pos = pick(i < half);
22245 var size = Math.floor(Math.random() * 100 + 160);
22246 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
22247 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
22248 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;
22249 });
22250 })();
22251
22252 (function spawnCodeParticles() {
22253 var container = document.getElementById('code-particles');
22254 if (!container) return;
22255 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'];
22256 for (var i = 0; i < 38; i++) {
22257 (function(idx) {
22258 var el = document.createElement('span');
22259 el.className = 'code-particle';
22260 el.textContent = snippets[idx % snippets.length];
22261 var left = Math.random() * 94 + 2;
22262 var top = Math.random() * 88 + 6;
22263 var dur = (Math.random() * 10 + 9).toFixed(1);
22264 var delay = (Math.random() * 18).toFixed(1);
22265 var rot = (Math.random() * 26 - 13).toFixed(1);
22266 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22267 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';
22268 container.appendChild(el);
22269 })(i);
22270 }
22271 })();
22272
22273 {% if pdf_generating %}
22274 // Poll for PDF readiness and swap the disabled button to a live link once done.
22275 (function() {
22276 var openBtn = document.getElementById('pdf-open-btn');
22277 var dlBtn = document.getElementById('pdf-download-btn');
22278 function checkPdf() {
22279 fetch('/api/runs/{{ run_id }}/pdf-status')
22280 .then(function(r) { return r.json(); })
22281 .then(function(d) {
22282 if (d.ready) {
22283 if (openBtn) {
22284 var a = document.createElement('a');
22285 a.className = 'button';
22286 a.id = 'pdf-open-btn';
22287 a.href = '/runs/pdf/{{ run_id }}';
22288 a.target = '_blank';
22289 a.rel = 'noopener';
22290 a.textContent = 'Open PDF';
22291 openBtn.replaceWith(a);
22292 }
22293 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
22294 } else {
22295 setTimeout(checkPdf, 3000);
22296 }
22297 })
22298 .catch(function() { setTimeout(checkPdf, 5000); });
22299 }
22300 setTimeout(checkPdf, 3000);
22301 })();
22302 {% endif %}
22303
22304 })();
22305 </script>
22306 <script nonce="{{ csp_nonce }}">
22307 (function(){
22308 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'}];
22309 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);});}
22310 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22311 function init(){
22312 var btn=document.getElementById('settings-btn');if(!btn)return;
22313 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22314 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>';
22315 document.body.appendChild(m);
22316 var g=document.getElementById('scheme-grid');
22317 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);});
22318 var cl=document.getElementById('settings-close');
22319 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);
22320 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');});
22321 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22322 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22323 }
22324 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22325 }());
22326 </script>
22327 <footer class="site-footer">
22328 local code analysis - metrics, history and reports
22329 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22330 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22331 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22332 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22333 · <a href="/api-docs" rel="noopener">REST API</a>
22334 </footer>
22335 {% if confluence_configured %}
22336 <script nonce="{{ csp_nonce }}">
22337 (function() {
22338 var postBtn = document.getElementById('postConfluenceBtn');
22339 var copyBtn = document.getElementById('copyWikiBtn');
22340 var modal = document.getElementById('confluenceModal');
22341 if (!postBtn || !modal) return;
22342
22343 postBtn.addEventListener('click', function() {
22344 document.getElementById('confStatus').style.display = 'none';
22345 modal.style.display = 'flex';
22346 });
22347 document.getElementById('confCancelBtn').addEventListener('click', function() {
22348 modal.style.display = 'none';
22349 });
22350 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22351
22352 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
22353 var btn = this;
22354 btn.disabled = true;
22355 var status = document.getElementById('confStatus');
22356 status.style.display = 'block';
22357 status.style.background = '#dbeafe';
22358 status.style.color = '#1e40af';
22359 status.textContent = 'Posting to Confluence…';
22360 var resp = await fetch('/api/confluence/post', {
22361 method: 'POST',
22362 headers: { 'Content-Type': 'application/json' },
22363 body: JSON.stringify({
22364 run_id: '{{ run_id }}',
22365 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
22366 report_url: document.getElementById('confReportUrl').value.trim() || null
22367 })
22368 });
22369 var data = await resp.json();
22370 if (data.ok) {
22371 status.style.background = '#dcfce7'; status.style.color = '#166534';
22372 status.textContent = 'Posted! Page ID: ' + data.page_id;
22373 } else {
22374 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22375 status.textContent = 'Error: ' + (data.error || 'Unknown error');
22376 }
22377 btn.disabled = false;
22378 });
22379
22380 if (copyBtn) {
22381 copyBtn.addEventListener('click', async function() {
22382 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
22383 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
22384 var text = await resp.text();
22385 try {
22386 await navigator.clipboard.writeText(text);
22387 var orig = copyBtn.textContent;
22388 copyBtn.textContent = 'Copied!';
22389 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
22390 } catch(e) {
22391 alert('Clipboard write failed — check browser permissions.');
22392 }
22393 });
22394 }
22395 })();
22396 </script>
22397 {% endif %}
22398 <script nonce="{{ csp_nonce }}">
22399 (function() {
22400 var deleteBtn = document.getElementById('delete-run-btn');
22401 var modal = document.getElementById('delete-run-modal');
22402 var cancelBtn = document.getElementById('delete-run-cancel');
22403 var confirmBtn= document.getElementById('delete-run-confirm');
22404 if (!deleteBtn || !modal) return;
22405 deleteBtn.addEventListener('click', function() {
22406 document.getElementById('delete-run-status').style.display = 'none';
22407 modal.style.display = 'flex';
22408 });
22409 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
22410 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
22411 confirmBtn.addEventListener('click', async function() {
22412 confirmBtn.disabled = true;
22413 cancelBtn.disabled = true;
22414 var status = document.getElementById('delete-run-status');
22415 status.style.display = 'block';
22416 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
22417 status.textContent = 'Deleting…';
22418 try {
22419 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
22420 if (resp.status === 204 || resp.ok) {
22421 status.style.background = '#dcfce7'; status.style.color = '#166534';
22422 status.textContent = 'Deleted. Redirecting…';
22423 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
22424 } else {
22425 var d = await resp.json().catch(function(){return {};});
22426 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22427 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
22428 confirmBtn.disabled = false;
22429 cancelBtn.disabled = false;
22430 }
22431 } catch (e) {
22432 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
22433 status.textContent = 'Network error: ' + String(e);
22434 confirmBtn.disabled = false;
22435 cancelBtn.disabled = false;
22436 }
22437 });
22438 })();
22439 </script>
22440 <script nonce="{{ csp_nonce }}">(function(){
22441 var bundleBtn = document.getElementById('download-bundle-btn');
22442 if (bundleBtn) {
22443 bundleBtn.addEventListener('click', function() {
22444 bundleBtn.disabled = true;
22445 var orig = bundleBtn.textContent;
22446 bundleBtn.textContent = 'Preparing…';
22447 fetch('/api/runs/{{ run_id }}/bundle')
22448 .then(function(r) {
22449 if (!r.ok) throw new Error('HTTP ' + r.status);
22450 return r.blob();
22451 })
22452 .then(function(blob) {
22453 var url = URL.createObjectURL(blob);
22454 var a = document.createElement('a');
22455 a.href = url;
22456 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
22457 document.body.appendChild(a);
22458 a.click();
22459 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
22460 bundleBtn.disabled = false;
22461 bundleBtn.textContent = orig;
22462 })
22463 .catch(function(e) {
22464 bundleBtn.disabled = false;
22465 bundleBtn.textContent = orig;
22466 alert('Bundle download failed: ' + String(e));
22467 });
22468 });
22469 }
22470 })();</script>
22471 <script nonce="{{ csp_nonce }}">(function(){
22472 var dot=document.getElementById('status-dot');
22473 var pingEl=document.getElementById('server-ping-ms');
22474 var tipEl=document.getElementById('server-tip-ping');
22475 var fm=document.getElementById('footer-mode');
22476 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)';}}
22477 function doPing(){
22478 var t0=performance.now();
22479 fetch('/healthz',{cache:'no-store'})
22480 .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);})
22481 .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)';}});
22482 }
22483 doPing();
22484 setInterval(doPing,5000);
22485 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');}
22486 })();</script>
22487 {% if let Some(banner) = report_header_footer %}
22488 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
22489 {% endif %}
22490</body>
22491</html>
22492"##,
22493 ext = "html"
22494)]
22495#[allow(clippy::struct_excessive_bools)]
22497struct ResultTemplate {
22498 version: &'static str,
22499 report_title: String,
22500 project_path: String,
22501 output_dir: String,
22502 run_id: String,
22503 files_analyzed: u64,
22504 files_skipped: u64,
22505 physical_lines: u64,
22506 code_lines: u64,
22507 comment_lines: u64,
22508 blank_lines: u64,
22509 mixed_lines: u64,
22510 functions: u64,
22511 classes: u64,
22512 variables: u64,
22513 imports: u64,
22514 html_url: Option<String>,
22515 pdf_url: Option<String>,
22516 json_url: Option<String>,
22517 html_download_url: Option<String>,
22518 pdf_download_url: Option<String>,
22519 json_download_url: Option<String>,
22520 html_path: Option<String>,
22521 json_path: Option<String>,
22522 prev_run_id: Option<String>,
22523 prev_run_timestamp: Option<String>,
22524 prev_run_code_lines: Option<u64>,
22525 prev_fa_str: String,
22527 prev_fs_str: String,
22528 prev_pl_str: String,
22529 prev_cl_str: String,
22530 prev_cml_str: String,
22531 prev_bl_str: String,
22532 delta_fa_str: String,
22534 delta_fa_class: String,
22535 delta_fs_str: String,
22536 delta_fs_class: String,
22537 delta_pl_str: String,
22538 delta_pl_class: String,
22539 delta_cl_str: String,
22540 delta_cl_class: String,
22541 delta_cml_str: String,
22542 delta_cml_class: String,
22543 delta_bl_str: String,
22544 delta_bl_class: String,
22545 delta_lines_added: Option<i64>,
22547 delta_lines_removed: Option<i64>,
22548 delta_lines_net_str: String,
22549 delta_lines_net_class: String,
22550 delta_files_added: Option<usize>,
22551 delta_files_removed: Option<usize>,
22552 delta_files_modified: Option<usize>,
22553 delta_files_unchanged: Option<usize>,
22554 delta_unmodified_lines: Option<u64>,
22555 git_branch: Option<String>,
22557 git_branch_url: Option<String>,
22558 git_commit: Option<String>,
22559 git_commit_long: Option<String>,
22560 git_author: Option<String>,
22561 git_commit_url: Option<String>,
22562 scan_performed_by: String,
22564 scan_time_display: String,
22565 os_display: String,
22566 test_count: u64,
22567 prev_scan_count: usize,
22569 current_scan_number: usize,
22570 submodule_rows: Vec<SubmoduleRow>,
22572 scan_config_url: String,
22573 lang_chart_json: String,
22574 #[allow(dead_code)]
22576 scatter_chart_json: String,
22577 #[allow(dead_code)]
22578 semantic_chart_json: String,
22579 #[allow(dead_code)]
22580 submodule_chart_json: String,
22581 #[allow(dead_code)]
22582 has_submodule_data: bool,
22583 #[allow(dead_code)]
22584 has_semantic_data: bool,
22585 pdf_generating: bool,
22586 csp_nonce: String,
22587 confluence_configured: bool,
22589 server_mode: bool,
22590 report_header_footer: Option<String>,
22592 run_id_short: String,
22593 #[allow(dead_code)]
22595 is_offline: bool,
22596 cyclomatic_complexity: u64,
22598 lsloc: Option<u64>,
22600 uloc: u64,
22602 dryness_pct_str: String,
22604 duplicate_group_count: usize,
22606 has_cocomo: bool,
22608 cocomo_effort_str: String,
22610 cocomo_duration_str: String,
22612 cocomo_staff_str: String,
22614 cocomo_ksloc_str: String,
22616 cocomo_mode_label: String,
22618 cocomo_mode_tooltip: String,
22620 complexity_alert: u32,
22622}
22623
22624#[derive(Template)]
22625#[template(
22626 source = r##"
22627<!doctype html>
22628<html lang="en">
22629<head>
22630 <meta charset="utf-8">
22631 <meta name="viewport" content="width=device-width, initial-scale=1">
22632 <title>OxideSLOC | Analyzing…</title>
22633 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22634 <style nonce="{{ csp_nonce }}">
22635 :root {
22636 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22637 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22638 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22639 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22640 }
22641 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22642 *{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;}
22643 .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);}
22644 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22645 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
22646 .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));}
22647 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22648 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22649 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22650 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22651 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22652 @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; } }
22653 .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;}
22654 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22655 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22656 .page-body{padding:32px 24px 36px;}
22657 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
22658 .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;}
22659 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
22660 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
22661 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
22662 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
22663 .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;}
22664 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
22665 .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;}
22666 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
22667 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
22668 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
22669 .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;}
22670 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
22671 .hidden{display:none!important;}
22672 .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;}
22673 .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;}
22674 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
22675 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
22676 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
22677 .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);}
22678 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
22679 .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;}
22680 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
22681 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22682 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22683 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
22684 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22685 .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;}
22686 @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));}}
22687 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22688 .site-footer a{color:var(--muted);}
22689 .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;}
22690 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
22691 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
22692 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
22693 </style>
22694</head>
22695<body>
22696 <div class="background-watermarks" aria-hidden="true">
22697 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22698 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22699 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22700 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22701 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22702 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22703 </div>
22704 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22705 <nav class="top-nav">
22706 <div class="top-nav-inner">
22707 <a href="/" class="brand">
22708 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
22709 <div class="brand-copy">
22710 <h1 class="brand-title">OxideSLOC</h1>
22711 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
22712 </div>
22713 </a>
22714 <div class="nav-right">
22715 <a class="nav-pill" href="/">Home</a>
22716 <div class="nav-dropdown">
22717 <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>
22718 <div class="nav-dropdown-menu">
22719 <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>
22720 </div>
22721 </div>
22722 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22723 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22724 <div class="nav-dropdown">
22725 <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>
22726 <div class="nav-dropdown-menu">
22727 <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>
22728 </div>
22729 </div>
22730 <div class="server-status-wrap" id="server-status-wrap">
22731 <div class="nav-pill server-online-pill" id="server-status-pill">
22732 <span class="status-dot" id="status-dot"></span>
22733 <span id="server-status-label">Server</span>
22734 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22735 </div>
22736 <div class="server-status-tip">
22737 OxideSLOC is running — accessible on your network.
22738 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22739 </div>
22740 </div>
22741 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22742 <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>
22743 </button>
22744 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22745 <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>
22746 <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>
22747 </button>
22748 </div>
22749 </div>
22750 </nav>
22751 <div class="page-body">
22752 <div class="wait-panel">
22753 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
22754 <h2 class="wait-title">Analyzing your project…</h2>
22755 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
22756 <div class="path-block">{{ project_path }}</div>
22757 <div class="metrics-row">
22758 <div class="metric-card">
22759 <div class="metric-label">Elapsed</div>
22760 <div class="metric-value" id="elapsed">0s</div>
22761 </div>
22762 <div class="metric-card">
22763 <div class="metric-label">Phase</div>
22764 <div class="metric-value" id="phase">Starting</div>
22765 </div>
22766 <div class="metric-card hidden" id="files-card">
22767 <div class="metric-label">Files</div>
22768 <div class="metric-value" id="files-progress">0</div>
22769 </div>
22770 </div>
22771 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
22772 <div class="warn-slow hidden" id="warn-slow">
22773 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.
22774 </div>
22775 <div class="err-panel hidden" id="err-panel">
22776 <strong>Analysis failed</strong>
22777 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
22778 </div>
22779 <div class="actions hidden" id="actions">
22780 <a href="/scan" class="btn-primary">Try Again</a>
22781 <a href="/view-reports" class="btn-outline">View Reports</a>
22782 </div>
22783 </div>
22784 </div>
22785 <script nonce="{{ csp_nonce }}">
22786 (function() {
22787 var WAIT_ID = {{ wait_id_json|safe }};
22788 var startTime = Date.now();
22789 var pollInterval = 1500;
22790 var retries = 0;
22791 var maxRetries = 5;
22792 var warnShown = false;
22793
22794 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();}
22795
22796 function elapsed() {
22797 return Math.floor((Date.now() - startTime) / 1000);
22798 }
22799
22800 function updateElapsed() {
22801 var s = elapsed();
22802 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
22803 }
22804
22805 function setPhase(txt) {
22806 document.getElementById('phase').textContent = txt;
22807 }
22808
22809 var elapsedTimer = setInterval(updateElapsed, 1000);
22810
22811 function poll() {
22812 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
22813 .then(function(r) {
22814 if (!r.ok) throw new Error('HTTP ' + r.status);
22815 return r.json();
22816 })
22817 .then(function(data) {
22818 retries = 0;
22819 if (data.state === 'complete') {
22820 clearInterval(elapsedTimer);
22821 setPhase('Done');
22822 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
22823 } else if (data.state === 'failed') {
22824 clearInterval(elapsedTimer);
22825 setPhase('Failed');
22826 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
22827 document.getElementById('err-panel').classList.remove('hidden');
22828 document.getElementById('actions').classList.remove('hidden');
22829 } else {
22830 // still running
22831 var s = elapsed();
22832 if (s > 90 && !warnShown) {
22833 warnShown = true;
22834 document.getElementById('warn-slow').classList.remove('hidden');
22835 }
22836 setPhase(data.phase || 'Running');
22837 var fd = data.files_done || 0, ft = data.files_total || 0;
22838 if (ft > 0) {
22839 var card = document.getElementById('files-card');
22840 if (card) card.classList.remove('hidden');
22841 var fp = document.getElementById('files-progress');
22842 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
22843 }
22844 setTimeout(poll, pollInterval);
22845 }
22846 })
22847 .catch(function(err) {
22848 retries++;
22849 if (retries >= maxRetries) {
22850 clearInterval(elapsedTimer);
22851 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
22852 document.getElementById('err-panel').classList.remove('hidden');
22853 document.getElementById('actions').classList.remove('hidden');
22854 } else {
22855 // exponential back-off capped at 8s
22856 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
22857 }
22858 });
22859 }
22860
22861 setTimeout(poll, pollInterval);
22862
22863 // If the browser restores this page from bfcache (Back after viewing results),
22864 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
22865 window.addEventListener("pageshow", function(e) {
22866 if (e.persisted) { setTimeout(poll, 200); }
22867 });
22868 })();
22869 </script>
22870 <footer class="site-footer">
22871 local code analysis - metrics, history and reports
22872 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22873 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22874 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22875 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22876 · <a href="/api-docs" rel="noopener">REST API</a>
22877 </footer>
22878 <script nonce="{{ csp_nonce }}">
22879 (function(){
22880 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
22881 if(s==="dark")b.classList.add("dark-theme");
22882 var tt=document.getElementById("theme-toggle");
22883 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
22884 })();
22885 (function spawnCodeParticles(){
22886 var c=document.getElementById('code-particles');if(!c)return;
22887 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'];
22888 for(var i=0;i<32;i++){(function(idx){
22889 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
22890 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
22891 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
22892 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
22893 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
22894 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
22895 c.appendChild(el);
22896 })(i);}
22897 })();
22898 (function randomizeWatermarks(){
22899 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22900 var placed=[];
22901 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;}
22902 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];}
22903 var half=Math.floor(wms.length/2);
22904 wms.forEach(function(img,i){
22905 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
22906 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
22907 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
22908 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
22909 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
22910 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
22911 });
22912 })();
22913 </script>
22914 <script nonce="{{ csp_nonce }}">
22915 (function(){
22916 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'}];
22917 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);});}
22918 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22919 function init(){
22920 var btn=document.getElementById('settings-btn');if(!btn)return;
22921 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22922 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>';
22923 document.body.appendChild(m);
22924 var g=document.getElementById('scheme-grid');
22925 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);});
22926 var cl=document.getElementById('settings-close');
22927 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);
22928 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');});
22929 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22930 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22931 }
22932 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22933 }());
22934 </script>
22935 <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]';
22936 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;}
22937 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>
22938</body>
22939</html>
22940"##,
22941 ext = "html"
22942)]
22943struct ScanWaitTemplate {
22944 version: &'static str,
22945 wait_id_json: String,
22946 project_path: String,
22947 csp_nonce: String,
22948}
22949
22950#[derive(Template)]
22951#[template(
22952 source = r##"
22953<!doctype html>
22954<html lang="en">
22955<head>
22956 <meta charset="utf-8">
22957 <meta name="viewport" content="width=device-width, initial-scale=1">
22958 <title>OxideSLOC | Error</title>
22959 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22960 <style nonce="{{ csp_nonce }}">
22961 :root {
22962 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22963 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22964 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
22965 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22966 }
22967 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
22968 *{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;}
22969 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22970 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22971 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
22972 .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);}
22973 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22974 .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));}
22975 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22976 .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;}
22977 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22978 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22979 @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; } }
22980 .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;}
22981 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22982 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
22983 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22984 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22985 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22986 .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;}
22987 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22988 .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);}
22989 .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;}
22990 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22991 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22992 .settings-modal-body{padding:14px 16px 16px;}
22993 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22994 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22995 .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;}
22996 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22997 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22998 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22999 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23000 .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;}
23001 .tz-select:focus{border-color:var(--oxide);}
23002 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23003 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
23004 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23005 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23006 .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;}
23007 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23008 .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);}
23009 .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;}
23010 .btn-secondary:hover{background:var(--line);}
23011 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
23012 .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;}
23013 .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;}
23014 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
23015 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
23016 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
23017 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
23018 .bug-report-panel.open{display:flex;}
23019 .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;}
23020 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
23021 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
23022 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
23023 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
23024 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
23025 .br-network-badge.online .br-net-dot{background:#2a6846;}
23026 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
23027 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
23028 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
23029 .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;}
23030 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
23031 .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;}
23032 .btn-sm:hover{background:var(--line);}
23033 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
23034 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
23035 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
23036 .bug-report-hint a:hover{text-decoration:underline;}
23037 .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;}
23038 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23039 .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;}
23040 .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;}
23041 .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;}
23042 @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));}}
23043 .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;}
23044 </style>
23045</head>
23046<body>
23047 <div class="background-watermarks" aria-hidden="true">
23048 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23049 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23050 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23051 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23052 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23053 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23054 </div>
23055 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23056 <div class="top-nav">
23057 <div class="top-nav-inner">
23058 <a class="brand" href="/">
23059 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23060 <div class="brand-copy">
23061 <div class="brand-title">OxideSLOC</div>
23062 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23063 </div>
23064 </a>
23065 <div class="nav-right">
23066 <a class="nav-pill" href="/">Home</a>
23067 <div class="nav-dropdown">
23068 <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>
23069 <div class="nav-dropdown-menu">
23070 <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>
23071 </div>
23072 </div>
23073 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23074 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23075 <div class="nav-dropdown">
23076 <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>
23077 <div class="nav-dropdown-menu">
23078 <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>
23079 </div>
23080 </div>
23081 <div class="server-status-wrap" id="server-status-wrap">
23082 <div class="nav-pill server-online-pill" id="server-status-pill">
23083 <span class="status-dot" id="status-dot"></span>
23084 <span id="server-status-label">Server</span>
23085 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23086 </div>
23087 <div class="server-status-tip">
23088 OxideSLOC is running — accessible on your network.
23089 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23090 </div>
23091 </div>
23092 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23093 <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>
23094 </button>
23095 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23096 <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>
23097 <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>
23098 </button>
23099 </div>
23100 </div>
23101 </div>
23102
23103 <div class="page">
23104 <div class="panel">
23105 <h1>Error</h1>
23106 <div class="error-box" id="error-msg-text">{{ message }}</div>
23107 <div id="br-meta" hidden
23108 data-version="{{ version }}"
23109 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
23110 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
23111 <div class="actions">
23112 <a class="btn-primary" href="/scan">Back to setup</a>
23113 {% if let Some(report_url) = last_report_url %}
23114 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
23115 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
23116 {% else %}
23117 <a class="btn-secondary" href="/view-reports">View Reports</a>
23118 {% endif %}
23119 </div>
23120 <div class="bug-report-section" id="bug-report-section">
23121 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
23122 <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>
23123 Generate Bug Report
23124 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
23125 </button>
23126 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
23127 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
23128 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
23129 <div class="bug-report-btns">
23130 <button type="button" class="btn-sm" id="bug-report-copy">
23131 <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>
23132 Copy to clipboard
23133 </button>
23134 <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;">
23135 <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>
23136 Open GitHub Issue
23137 </a>
23138 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
23139 <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>
23140 Save as file
23141 </button>
23142 </div>
23143 <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>
23144 <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>
23145 </div>
23146 </div>
23147 </div>
23148 </div>
23149 <footer class="site-footer">
23150 oxide-sloc v{{ version }} — local code metrics workbench ·
23151 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23152 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23153 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23154 · <a href="/api-docs" rel="noopener">REST API</a>
23155 </footer>
23156 <script nonce="{{ csp_nonce }}">(function(){
23157 var meta=document.getElementById('br-meta');
23158 var pre=document.getElementById('bug-report-pre');
23159 var copyBtn=document.getElementById('bug-report-copy');
23160 var trigger=document.getElementById('bug-report-trigger');
23161 var panel=document.getElementById('bug-report-panel');
23162 var networkBadge=document.getElementById('br-network-badge');
23163 var networkLabel=document.getElementById('br-network-label');
23164 var ghLink=document.getElementById('bug-report-github-link');
23165 var saveBtn=document.getElementById('bug-report-save');
23166 var hintOnline=document.getElementById('br-hint-online');
23167 var hintOffline=document.getElementById('br-hint-offline');
23168 if(!meta||!pre)return;
23169 var ver=meta.getAttribute('data-version')||'';
23170 var runId=meta.getAttribute('data-run-id')||'';
23171 var code=meta.getAttribute('data-error-code')||'';
23172 var msgEl=document.getElementById('error-msg-text');
23173 var msg=msgEl?msgEl.textContent.trim():'';
23174 function getBrowser(){
23175 var ua=navigator.userAgent;
23176 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
23177 if(!m)return 'Unknown browser';
23178 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
23179 return n+' '+m[2];
23180 }
23181 var lines=['oxide-sloc Bug Report','==============================',''];
23182 lines.push('App version: v'+ver);
23183 if(code)lines.push('HTTP status: '+code);
23184 if(runId)lines.push('Run ID: '+runId);
23185 lines.push('Page: '+window.location.pathname+(window.location.search||''));
23186 lines.push('Timestamp: '+new Date().toISOString());
23187 lines.push('Browser: '+getBrowser());
23188 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
23189 lines.push('');
23190 lines.push('Error message:');
23191 lines.push(msg);
23192 lines.push('');
23193 lines.push('Steps to reproduce:');
23194 lines.push(' 1. ');
23195 lines.push('');
23196 lines.push('Expected behavior:');
23197 lines.push(' ');
23198 pre.textContent=lines.join('\n');
23199 function applyNetwork(online){
23200 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
23201 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
23202 if(ghLink){
23203 if(online){
23204 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
23205 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
23206 }
23207 ghLink.style.display=online?'inline-flex':'none';
23208 }
23209 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
23210 if(hintOnline)hintOnline.style.display=online?'block':'none';
23211 if(hintOffline)hintOffline.style.display=online?'none':'block';
23212 }
23213 applyNetwork(navigator.onLine);
23214 var probed=false;
23215 function probeNetwork(){
23216 if(probed)return;probed=true;
23217 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
23218 var probeIdx=0;
23219 function tryNext(){
23220 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
23221 var u=probeUrls[probeIdx++];
23222 var c2=new AbortController();
23223 var t2=setTimeout(function(){c2.abort();},4000);
23224 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
23225 .then(function(){clearTimeout(t2);applyNetwork(true);})
23226 .catch(function(){clearTimeout(t2);tryNext();});
23227 }
23228 tryNext();
23229 }
23230 if(trigger&&panel){
23231 trigger.addEventListener('click',function(){
23232 var open=panel.classList.toggle('open');
23233 trigger.classList.toggle('open',open);
23234 trigger.setAttribute('aria-expanded',open?'true':'false');
23235 if(open)probeNetwork();
23236 });
23237 }
23238 if(copyBtn){
23239 copyBtn.addEventListener('click',function(){
23240 var txt=pre.textContent;
23241 if(navigator.clipboard&&navigator.clipboard.writeText){
23242 navigator.clipboard.writeText(txt).then(function(){
23243 copyBtn.textContent='✓ Copied!';
23244 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);
23245 });
23246 }else{
23247 var ta=document.createElement('textarea');
23248 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
23249 document.body.appendChild(ta);ta.select();
23250 try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
23251 document.body.removeChild(ta);
23252 }
23253 });
23254 }
23255 if(saveBtn){
23256 saveBtn.addEventListener('click',function(){
23257 var txt=pre.textContent;
23258 var blob=new Blob([txt],{type:'text/plain'});
23259 var url=URL.createObjectURL(blob);
23260 var a=document.createElement('a');
23261 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
23262 document.body.appendChild(a);a.click();
23263 document.body.removeChild(a);URL.revokeObjectURL(url);
23264 });
23265 }
23266 })();</script>
23267 <script nonce="{{ csp_nonce }}">
23268 (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");});})();
23269 (function spawnCodeParticles() {
23270 var container = document.getElementById('code-particles');
23271 if (!container) return;
23272 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'];
23273 for (var i = 0; i < 38; i++) {
23274 (function(idx) {
23275 var el = document.createElement('span');
23276 el.className = 'code-particle';
23277 el.textContent = snippets[idx % snippets.length];
23278 var left = Math.random() * 94 + 2;
23279 var top = Math.random() * 88 + 6;
23280 var dur = (Math.random() * 10 + 9).toFixed(1);
23281 var delay = (Math.random() * 18).toFixed(1);
23282 var rot = (Math.random() * 26 - 13).toFixed(1);
23283 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
23284 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';
23285 container.appendChild(el);
23286 })(i);
23287 }
23288 })();
23289 (function randomizeWatermarks() {
23290 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23291 var placed = [];
23292 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; }
23293 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]; }
23294 var half = Math.floor(wms.length/2);
23295 wms.forEach(function(img, i) {
23296 var pos = pick(i < half);
23297 var w = Math.floor(Math.random()*60+80);
23298 var rot = (Math.random()*40-20).toFixed(1);
23299 var op = (Math.random()*0.08+0.05).toFixed(2);
23300 var animDur = (Math.random()*6+5).toFixed(1);
23301 var animDelay = (Math.random()*10).toFixed(1);
23302 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';
23303 });
23304 })();
23305 </script>
23306 <script nonce="{{ csp_nonce }}">
23307 (function(){
23308 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'}];
23309 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);});}
23310 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23311 function init(){
23312 var btn=document.getElementById('settings-btn');if(!btn)return;
23313 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23314 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>';
23315 document.body.appendChild(m);
23316 var g=document.getElementById('scheme-grid');
23317 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);});
23318 var cl=document.getElementById('settings-close');
23319 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);
23320 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');});
23321 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23322 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23323 }
23324 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23325 }());
23326 </script>
23327 <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]';
23328 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;}
23329 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>
23330</body>
23331</html>
23332"##,
23333 ext = "html"
23334)]
23335struct ErrorTemplate {
23336 message: String,
23337 last_report_url: Option<String>,
23339 last_report_label: Option<String>,
23341 run_id: Option<String>,
23343 error_code: Option<u16>,
23345 csp_nonce: String,
23346 version: &'static str,
23347}
23348
23349#[derive(Template)]
23352#[template(
23353 source = r##"
23354<!doctype html>
23355<html lang="en">
23356<head>
23357 <meta charset="utf-8">
23358 <meta name="viewport" content="width=device-width, initial-scale=1">
23359 <title>OxideSLOC | Locate Report</title>
23360 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23361 <style nonce="{{ csp_nonce }}">
23362 :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);}
23363 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
23364 *{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;}
23365 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23366 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23367 .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);}
23368 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23369 .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));}
23370 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23371 .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;}
23372 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23373 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23374 @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;}}
23375 .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;}
23376 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23377 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23378 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23379 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23380 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
23381 .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;}
23382 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23383 .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);}
23384 .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;}
23385 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23386 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23387 .settings-modal-body{padding:14px 16px 16px;}
23388 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23389 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23390 .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;}
23391 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23392 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23393 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23394 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23395 .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;}
23396 .tz-select:focus{border-color:var(--oxide);}
23397 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23398 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23399 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23400 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
23401 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
23402 .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;}
23403 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
23404 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23405 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23406 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23407 .locate-row{display:flex;gap:8px;align-items:stretch;}
23408 .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;}
23409 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23410 body.dark-theme .locate-input{background:var(--surface-2);}
23411 .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;}
23412 .warning-banner.show{display:flex;}
23413 .warning-banner svg{flex:0 0 auto;}
23414 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
23415 .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;}
23416 .error-inline.show{display:flex;}
23417 .error-inline svg{flex:0 0 auto;margin-top:2px;}
23418 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
23419 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
23420 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
23421 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
23422 .err-kv-p{margin:0 0 4px;}
23423 .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;}
23424 .success-inline.show{display:flex;}
23425 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23426 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
23427 .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;}
23428 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
23429 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
23430 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
23431 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
23432 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
23433 .fh-row:last-child{border-bottom:none;}
23434 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
23435 .fh-dir{font-weight:800;color:var(--text);}
23436 .fh-hl{color:var(--oxide);font-weight:700;}
23437 .fh-muted{color:var(--muted);}
23438 .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;}
23439 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
23440 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
23441 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
23442 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
23443 .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;}
23444 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
23445 .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;}
23446 .btn-secondary:hover{background:var(--line);}
23447 .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;}
23448 .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;}
23449 .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;}
23450 @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));}}
23451 .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;}
23452 .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;}
23453 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
23454 </style>
23455</head>
23456<body>
23457 <div class="background-watermarks" aria-hidden="true">
23458 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23459 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23460 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23461 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23462 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23463 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23464 </div>
23465 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23466 <div class="top-nav">
23467 <div class="top-nav-inner">
23468 <a class="brand" href="/">
23469 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23470 <div class="brand-copy">
23471 <div class="brand-title">OxideSLOC</div>
23472 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23473 </div>
23474 </a>
23475 <div class="nav-right">
23476 <a class="nav-pill" href="/">Home</a>
23477 <div class="nav-dropdown">
23478 <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>
23479 <div class="nav-dropdown-menu">
23480 <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>
23481 </div>
23482 </div>
23483 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23484 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23485 <div class="nav-dropdown">
23486 <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>
23487 <div class="nav-dropdown-menu">
23488 <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>
23489 </div>
23490 </div>
23491 <div class="server-status-wrap" id="server-status-wrap">
23492 <div class="nav-pill server-online-pill" id="server-status-pill">
23493 <span class="status-dot" id="status-dot"></span>
23494 <span id="server-status-label">Server</span>
23495 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23496 </div>
23497 <div class="server-status-tip">
23498 OxideSLOC is running — accessible on your network.
23499 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23500 </div>
23501 </div>
23502 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23503 <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>
23504 </button>
23505 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23506 <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>
23507 <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>
23508 </button>
23509 </div>
23510 </div>
23511 </div>
23512
23513 <div class="page">
23514 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
23515 <div class="panel">
23516 <h1>Report File Not Found</h1>
23517 <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>
23518 <div class="field-label">Missing file</div>
23519 <div class="filename-chip">
23520 <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>
23521 {{ expected_filename }}
23522 </div>
23523 <div class="locate-section">
23524 <h2>Locate Scan Output Folder</h2>
23525 <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>
23526 <p>OxideSLOC will find the correct files inside automatically.</p>
23527 <div class="locate-row">
23528 <input type="text" id="locate-file-input"
23529 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
23530 class="locate-input" autocomplete="off" spellcheck="false">
23531 {% if !server_mode %}
23532 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
23533 {% endif %}
23534 </div>
23535 <div class="warning-banner" id="filename-warning">
23536 <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>
23537 <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>
23538 </div>
23539 <div class="error-inline" id="locate-error">
23540 <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>
23541 <span id="locate-error-text"></span>
23542 </div>
23543 <div class="success-inline" id="locate-success">
23544 <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>
23545 <span>Scan restored — loading report…</span>
23546 </div>
23547 <div class="btn-row">
23548 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
23549 <a class="btn-secondary" href="/view-reports">View Reports</a>
23550 </div>
23551 <div class="folder-hint-shell">
23552 <div class="folder-hint-hdr">
23553 <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>
23554 Expected Folder Structure — Select the Top-Level Folder
23555 </div>
23556 <div class="folder-hint-body">
23557 <div class="fh-row">
23558 <span class="fh-tog">►</span>
23559 <span class="fh-dir">project_20260601-0029-…/</span>
23560 <span class="fh-badge">← select this</span>
23561 </div>
23562 <div class="fh-row fh-i1">
23563 <span class="fh-tog">►</span>
23564 <span class="fh-dir">html/</span>
23565 </div>
23566 <div class="fh-row fh-i2">
23567 <span class="fh-bul">•</span>
23568 <span class="fh-hl">{{ expected_filename }}</span>
23569 </div>
23570 <div class="fh-row fh-i1">
23571 <span class="fh-tog">►</span>
23572 <span class="fh-dir">json/</span>
23573 </div>
23574 <div class="fh-row fh-i2">
23575 <span class="fh-bul">•</span>
23576 <span class="fh-muted">result_*.json</span>
23577 </div>
23578 <div class="fh-row fh-i1">
23579 <span class="fh-tog">►</span>
23580 <span class="fh-dir">pdf/</span>
23581 </div>
23582 <div class="fh-row fh-i2">
23583 <span class="fh-bul">•</span>
23584 <span class="fh-muted">report_*.pdf</span>
23585 </div>
23586 <div class="fh-row fh-i1">
23587 <span class="fh-tog">►</span>
23588 <span class="fh-dir">excel/</span>
23589 </div>
23590 <div class="fh-row fh-i2">
23591 <span class="fh-bul">•</span>
23592 <span class="fh-muted">report_*.csv report_*.xlsx</span>
23593 </div>
23594 </div>
23595 </div>
23596 </div>
23597 </div>
23598 </div>
23599 <footer class="site-footer">
23600 oxide-sloc v{{ version }} — local code metrics workbench ·
23601 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23602 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23603 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23604 · <a href="/api-docs" rel="noopener">REST API</a>
23605 </footer>
23606 <script nonce="{{ csp_nonce }}">(function(){
23607 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
23608 if(s==="dark")b.classList.add("dark-theme");
23609 document.getElementById("theme-toggle").addEventListener("click",function(){
23610 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
23611 });
23612 })();</script>
23613 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
23614 var c=document.getElementById('code-particles');if(!c)return;
23615 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'];
23616 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);}
23617 })();
23618 (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>
23619 <script nonce="{{ csp_nonce }}">(function(){
23620 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'}];
23621 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);});}
23622 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23623 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');});}
23624 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23625 }());</script>
23626 <script nonce="{{ csp_nonce }}">(function(){
23627 var meta=document.getElementById('locate-meta');
23628 var inp=document.getElementById('locate-file-input');
23629 var browseBtn=document.getElementById('browse-locate-btn');
23630 var submitBtn=document.getElementById('locate-submit-btn');
23631 var warning=document.getElementById('filename-warning');
23632 var errBox=document.getElementById('locate-error');
23633 var errText=document.getElementById('locate-error-text');
23634 var okBox=document.getElementById('locate-success');
23635 var expected=meta?meta.getAttribute('data-expected'):'';
23636 var runId=meta?meta.getAttribute('data-run-id'):'';
23637 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
23638 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
23639 function showErr(msg){
23640 if(errText){
23641 errText.innerHTML='';
23642 var lines=msg.split('\n');
23643 var hasPairs=lines.some(function(l){return / : /.test(l);});
23644 if(!hasPairs){errText.textContent=msg;}
23645 else{
23646 var frag=document.createDocumentFragment();var tbl=null;
23647 lines.forEach(function(line){
23648 var m=line.match(/^(.*?) : (.*)$/);
23649 if(m){
23650 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
23651 var tr=document.createElement('tr');
23652 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
23653 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
23654 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
23655 } else {
23656 tbl=null;
23657 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
23658 }
23659 });
23660 errText.appendChild(frag);
23661 }
23662 }
23663 if(errBox)errBox.classList.add('show');
23664 if(okBox)okBox.classList.remove('show');
23665 }
23666 function clearErr(){
23667 if(errBox)errBox.classList.remove('show');
23668 if(okBox)okBox.classList.remove('show');
23669 }
23670 function validate(){
23671 var val=inp?inp.value.trim():'';
23672 clearErr();
23673 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
23674 if(submitBtn)submitBtn.disabled=false;
23675 if(warning){
23676 var name=basename(val);
23677 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
23678 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
23679 else warning.classList.remove('show');
23680 }
23681 }
23682 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
23683 if(browseBtn){
23684 browseBtn.addEventListener('click',function(){
23685 browseBtn.disabled=true;browseBtn.textContent='...';
23686 fetch('/pick-directory')
23687 .then(function(r){return r.ok?r.json():{cancelled:true};})
23688 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
23689 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23690 });
23691 }
23692 if(submitBtn){
23693 submitBtn.addEventListener('click',function(){
23694 var folder=inp?inp.value.trim():'';
23695 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
23696 clearErr();
23697 submitBtn.disabled=true;submitBtn.textContent='Restoring…';
23698 var body=new URLSearchParams();
23699 body.set('file_path',folder);
23700 body.set('redirect_url',redirectUrl);
23701 body.set('expected_run_id',runId);
23702 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23703 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
23704 .then(function(d){
23705 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23706 if(d&&d.ok){
23707 if(okBox)okBox.classList.add('show');
23708 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
23709 } else {
23710 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
23711 }
23712 })
23713 .catch(function(e){
23714 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
23715 showErr('Network error: '+String(e));
23716 });
23717 });
23718 }
23719 })();</script>
23720 <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>
23721</body>
23722</html>
23723"##,
23724 ext = "html"
23725)]
23726struct LocateFileTemplate {
23727 run_id: String,
23728 artifact_type: String,
23729 expected_filename: String,
23730 server_mode: bool,
23731 csp_nonce: String,
23732 version: &'static str,
23733}
23734
23735#[derive(Template)]
23738#[template(
23739 source = r##"
23740<!doctype html>
23741<html lang="en">
23742<head>
23743 <meta charset="utf-8">
23744 <meta name="viewport" content="width=device-width, initial-scale=1">
23745 <title>OxideSLOC | Locate Scan Files</title>
23746 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23747 <style nonce="{{ csp_nonce }}">
23748 :root {
23749 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
23750 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23751 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
23752 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23753 }
23754 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
23755 *{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;}
23756 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23757 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23758 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
23759 .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);}
23760 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23761 .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));}
23762 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
23763 .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;}
23764 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23765 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
23766 @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;}}
23767 .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;}
23768 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23769 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
23770 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23771 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23772 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23773 .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;}
23774 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23775 .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);}
23776 .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;}
23777 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23778 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23779 .settings-modal-body{padding:14px 16px 16px;}
23780 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23781 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23782 .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;}
23783 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23784 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23785 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23786 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23787 .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;}
23788 .tz-select:focus{border-color:var(--oxide);}
23789 .page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
23790 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
23791 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
23792 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
23793 .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;}
23794 .error-box.hidden{display:none;}
23795 .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;}
23796 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
23797 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
23798 .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;}
23799 .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;}
23800 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
23801 .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;}
23802 .btn-secondary:hover{background:var(--line);}
23803 .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;}
23804 .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;}
23805 .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;}
23806 @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));}}
23807 .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;}
23808 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
23809 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
23810 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
23811 .relocate-row{display:flex;gap:8px;align-items:stretch;}
23812 .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;}
23813 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
23814 body.dark-theme .relocate-input{background:var(--surface-2);}
23815 </style>
23816</head>
23817<body>
23818 <div class="background-watermarks" aria-hidden="true">
23819 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23820 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23821 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23822 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23823 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23824 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23825 </div>
23826 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23827 <div class="top-nav">
23828 <div class="top-nav-inner">
23829 <a class="brand" href="/">
23830 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
23831 <div class="brand-copy">
23832 <div class="brand-title">OxideSLOC</div>
23833 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
23834 </div>
23835 </a>
23836 <div class="nav-right">
23837 <a class="nav-pill" href="/">Home</a>
23838 <div class="nav-dropdown">
23839 <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>
23840 <div class="nav-dropdown-menu">
23841 <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>
23842 </div>
23843 </div>
23844 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
23845 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23846 <div class="nav-dropdown">
23847 <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>
23848 <div class="nav-dropdown-menu">
23849 <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>
23850 </div>
23851 </div>
23852 <div class="server-status-wrap" id="server-status-wrap">
23853 <div class="nav-pill server-online-pill" id="server-status-pill">
23854 <span class="status-dot" id="status-dot"></span>
23855 <span id="server-status-label">Server</span>
23856 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23857 </div>
23858 <div class="server-status-tip">
23859 OxideSLOC is running — accessible on your network.
23860 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23861 </div>
23862 </div>
23863 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23864 <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>
23865 </button>
23866 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23867 <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>
23868 <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>
23869 </button>
23870 </div>
23871 </div>
23872 </div>
23873
23874 <div class="page">
23875 <div class="panel">
23876 <h1>Scan Files Moved</h1>
23877 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
23878 <div class="error-box" id="relocate-error-box">{{ message }}</div>
23879 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
23880 <div class="relocate-section">
23881 <h2>Locate Scan Output</h2>
23882 <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
23883 <div class="relocate-row">
23884 <input type="text" id="relocate-folder" name="folder_path"
23885 value="{{ folder_hint }}"
23886 placeholder="Path to folder containing scan output..."
23887 class="relocate-input" autocomplete="off" spellcheck="false">
23888 {% if !server_mode %}
23889 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
23890 {% endif %}
23891 </div>
23892 <div style="margin-top:12px;">
23893 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
23894 </div>
23895 </div>
23896 <div class="actions">
23897 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
23898 <a class="btn-secondary" href="/view-reports">View Reports</a>
23899 </div>
23900 </div>
23901 </div>
23902 <footer class="site-footer">
23903 oxide-sloc v{{ version }} — local code metrics workbench ·
23904 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23905 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23906 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23907 · <a href="/api-docs" rel="noopener">REST API</a>
23908 </footer>
23909 <script nonce="{{ csp_nonce }}">
23910 (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");});})();
23911 (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);}})();
23912 (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;});})();
23913 </script>
23914 <script nonce="{{ csp_nonce }}">
23915 (function(){
23916 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'}];
23917 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);});}
23918 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23919 function init(){
23920 var btn=document.getElementById('settings-btn');if(!btn)return;
23921 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23922 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>';
23923 document.body.appendChild(m);
23924 var g=document.getElementById('scheme-grid');
23925 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);});
23926 var cl=document.getElementById('settings-close');
23927 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);
23928 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');});
23929 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23930 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23931 }
23932 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23933 }());
23934 (function(){
23935 var browseBtn=document.getElementById('browse-relocate-btn');
23936 if(browseBtn){
23937 browseBtn.addEventListener('click',function(){
23938 browseBtn.disabled=true;browseBtn.textContent='...';
23939 var inp=document.getElementById('relocate-folder');
23940 var hint=inp?inp.value:'';
23941 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
23942 .then(function(r){return r.ok?r.json():{cancelled:true};})
23943 .then(function(d){
23944 browseBtn.disabled=false;browseBtn.textContent='Browse…';
23945 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
23946 })
23947 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
23948 });
23949 }
23950 var restoreBtn=document.getElementById('restore-btn');
23951 var errBox=document.getElementById('relocate-error-box');
23952 var okBox=document.getElementById('relocate-success-box');
23953 if(restoreBtn){
23954 restoreBtn.addEventListener('click',function(){
23955 var inp=document.getElementById('relocate-folder');
23956 var folder=inp?inp.value.trim():'';
23957 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
23958 restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
23959 var body=new URLSearchParams();
23960 body.set('run_id','{{ run_id }}');
23961 body.set('redirect_url','{{ redirect_url }}');
23962 body.set('folder_path',folder);
23963 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
23964 .then(function(r){return r.json();})
23965 .then(function(d){
23966 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23967 if(d&&d.ok){
23968 if(errBox)errBox.classList.add('hidden');
23969 if(okBox){okBox.style.display='block';}
23970 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
23971 } else {
23972 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
23973 }
23974 })
23975 .catch(function(e){
23976 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
23977 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
23978 });
23979 });
23980 }
23981 }());
23982 </script>
23983 <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]';
23984 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;}
23985 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>
23986</body>
23987</html>
23988"##,
23989 ext = "html"
23990)]
23991struct RelocateScanTemplate {
23992 message: String,
23993 run_id: String,
23994 folder_hint: String,
23995 redirect_url: String,
23996 server_mode: bool,
23997 csp_nonce: String,
23998 version: &'static str,
23999}
24000
24001#[derive(Template)]
24004#[template(
24005 source = r##"
24006<!doctype html>
24007<html lang="en">
24008<head>
24009 <meta charset="utf-8">
24010 <meta name="viewport" content="width=device-width, initial-scale=1">
24011 <title>OxideSLOC | View Reports</title>
24012 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24013 <style nonce="{{ csp_nonce }}">
24014 :root {
24015 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24016 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24017 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24018 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24019 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
24020 }
24021 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; }
24022 *{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;}
24023 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24024 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24025 .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);}
24026 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24027 .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));}
24028 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24029 .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;}
24030 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24031 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24032 @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; } }
24033 .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;}
24034 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24035 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24036 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24037 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24038 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24039 .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;}
24040 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24041 .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);}
24042 .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;}
24043 .settings-close:hover{color:var(--text);background:var(--surface-2);}
24044 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24045 .settings-modal-body{padding:14px 16px 16px;}
24046 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24047 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24048 .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;}
24049 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24050 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24051 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24052 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24053 .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;}
24054 .tz-select:focus{border-color:var(--oxide);}
24055 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24056 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24057 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24058 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24059 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24060 .panel-meta{font-size:13px;color:var(--muted);}
24061 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24062 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24063 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24064 .per-page-label{font-size:13px;color:var(--muted);}
24065 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;}
24066 .filter-input{min-width:180px;cursor:text;}
24067 .table-wrap{width:100%;overflow-x:auto;}
24068 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
24069 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;}
24070 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24071 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24072 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24073 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24074 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24075 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24076 tr:last-child td{border-bottom:none;}
24077 tr:hover td{background:var(--surface-2);}
24078 .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);}
24079 .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);}
24080 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24081 .metric-num{font-weight:700;color:var(--text);}
24082 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24083 .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;}
24084 .btn:hover{background:var(--line);}
24085 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24086 .btn.primary:hover{opacity:.9;}
24087 .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;}
24088 .btn-back:hover{background:var(--line);}
24089 .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;}
24090 .export-btn:hover{background:var(--line);}
24091 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
24092 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
24093 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
24094 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24095 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24096 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24097 .pagination-info{font-size:13px;color:var(--muted);}
24098 .pagination-btns{display:flex;gap:6px;}
24099 .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;}
24100 .pg-btn:hover:not(:disabled){background:var(--line);}
24101 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24102 .pg-btn:disabled{opacity:.35;cursor:default;}
24103 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24104 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24105 .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;}
24106 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24107 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24108 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24109 .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);}
24110 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24111 .stat-chip:hover .stat-chip-tip{opacity:1;}
24112 .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;}
24113 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24114 .site-footer a{color:var(--muted);}
24115 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24116 .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%;}
24117 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
24118 .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;}
24119 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
24120 .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;}
24121 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
24122 .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;}
24123 .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;}
24124 .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;}
24125 @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));}}
24126 .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;}
24127 .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;}
24128 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24129 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24130 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24131 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24132 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24133 .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;}
24134 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24135 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24136 .watched-chip-rm:hover{color:var(--oxide);}
24137 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24138 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24139 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24140 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24141 .rpt-btn{min-width:58px;justify-content:center;}
24142 .flex-row{display:flex;align-items:center;gap:8px;}
24143 .report-cell{overflow:visible;white-space:normal;}
24144 #history-table col:nth-child(1){width:185px;}
24145 #history-table col:nth-child(2){width:220px;}
24146 #history-table col:nth-child(3){width:100px;}
24147 #history-table col:nth-child(4){width:72px;}
24148 #history-table col:nth-child(5){width:82px;}
24149 #history-table col:nth-child(6){width:82px;}
24150 #history-table col:nth-child(7){width:65px;}
24151 #history-table col:nth-child(8){width:90px;}
24152 #history-table col:nth-child(9){width:85px;}
24153 #history-table col:nth-child(10){width:115px;}
24154 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
24155 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
24156 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
24157 .submod-details summary::-webkit-details-marker{display:none;}
24158.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
24159 .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;}
24160 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
24161 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
24162 </style>
24163</head>
24164<body>
24165 <div class="background-watermarks" aria-hidden="true">
24166 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24167 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24168 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24169 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24170 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24171 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24172 </div>
24173 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24174 <div class="top-nav">
24175 <div class="top-nav-inner">
24176 <a class="brand" href="/">
24177 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24178 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
24179 </a>
24180 <div class="nav-right">
24181 <a class="nav-pill" href="/">Home</a>
24182 <div class="nav-dropdown">
24183 <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>
24184 <div class="nav-dropdown-menu">
24185 <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>
24186 </div>
24187 </div>
24188 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24189 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24190 <div class="nav-dropdown">
24191 <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>
24192 <div class="nav-dropdown-menu">
24193 <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>
24194 </div>
24195 </div>
24196 <div class="server-status-wrap" id="server-status-wrap">
24197 <div class="nav-pill server-online-pill" id="server-status-pill">
24198 <span class="status-dot" id="status-dot"></span>
24199 <span id="server-status-label">Server</span>
24200 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24201 </div>
24202 <div class="server-status-tip">
24203 OxideSLOC is running — accessible on your network.
24204 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24205 </div>
24206 </div>
24207 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24208 <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>
24209 </button>
24210 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24211 <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>
24212 <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>
24213 </button>
24214 </div>
24215 </div>
24216 </div>
24217
24218 <div class="page">
24219 {% if let Some(err) = browse_error %}
24220 <div class="toast-error">
24221 <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>
24222 {{ err }}
24223 </div>
24224 {% endif %}
24225 {% if linked_count > 0 %}
24226 <div class="toast-success">
24227 <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>
24228 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
24229 </div>
24230 {% endif %}
24231 <div class="watched-bar">
24232 <div class="watched-bar-left">
24233 <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>
24234 <span class="watched-label">Watched Folders</span>
24235 <div class="watched-chips">
24236 {% if server_mode %}
24237 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24238 {% else %}
24239 {% for dir in watched_dirs %}
24240 <span class="watched-chip">
24241 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24242 <form method="POST" action="/watched-dirs/remove" style="display:contents">
24243 <input type="hidden" name="folder_path" value="{{ dir }}">
24244 <input type="hidden" name="redirect_to" value="/view-reports">
24245 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
24246 </form>
24247 </span>
24248 {% endfor %}
24249 {% if watched_dirs.is_empty() %}
24250 <span class="watched-none">No folders watched — click Choose to add one</span>
24251 {% endif %}
24252 {% endif %}
24253 </div>
24254 </div>
24255 {% if !server_mode %}
24256 <div class="watched-bar-right">
24257 <button type="button" class="btn" id="add-watched-btn">
24258 <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>
24259 Choose
24260 </button>
24261 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24262 <input type="hidden" name="redirect_to" value="/view-reports">
24263 <button type="submit" class="btn">↻ Refresh</button>
24264 </form>
24265 </div>
24266 {% endif %}
24267 </div>
24268 {% if total_scans > 0 %}
24269 <div class="summary-strip">
24270 <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>
24271 <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>
24272 <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>
24273 <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>
24274 </div>
24275 {% endif %}
24276
24277 <section class="panel">
24278 <div class="panel-header">
24279 <div>
24280 <h1>View Reports</h1>
24281 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
24282 {% 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 %}
24283 </div>
24284 <div class="flex-row">
24285 <button type="button" class="export-btn" id="export-csv-btn">
24286 <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>
24287 Export CSV
24288 </button>
24289 <button type="button" class="export-btn" id="export-xls-btn">
24290 <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>
24291 Export Excel
24292 </button>
24293 </div>
24294 </div>
24295
24296 {% if entries.is_empty() %}
24297 <div class="empty-state">
24298 <strong>No reports with viewable HTML yet</strong>
24299 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.
24300 </div>
24301 {% else %}
24302 <div class="filter-row">
24303 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
24304 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
24305 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
24306 </div>
24307 <div class="table-wrap">
24308 <table id="history-table">
24309 <colgroup>
24310 <col><col><col><col><col><col><col><col><col><col>
24311 </colgroup>
24312 <thead>
24313 <tr id="history-thead">
24314 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24315 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24316 <th>Run ID<div class="col-resize-handle"></div></th>
24317 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24318 <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>
24319 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24320 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24321 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24322 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
24323 <th>Report<div class="col-resize-handle"></div></th>
24324 </tr>
24325 </thead>
24326 <tbody id="history-tbody">
24327 {% for entry in entries %}
24328 <tr class="history-row" data-run="{{ entry.run_id }}"
24329 data-timestamp="{{ entry.timestamp }}"
24330 data-project="{{ entry.project_label }}"
24331 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
24332 data-skipped="{{ entry.files_skipped }}"
24333 data-comments="{{ entry.comment_lines }}"
24334 data-blank="{{ entry.blank_lines }}"
24335 data-branch="{{ entry.git_branch }}"
24336 data-commit="{{ entry.git_commit }}"
24337 data-html-url="/runs/html/{{ entry.run_id }}">
24338 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
24339 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
24340 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
24341 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
24342 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
24343 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
24344 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
24345 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
24346 <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>
24347 <td class="report-cell">
24348 <div class="actions-cell">
24349 {% 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 %}
24350 {% 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 %}
24351 </div>
24352 {% if !entry.submodule_links.is_empty() %}
24353 <details class="submod-details">
24354 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
24355 <div class="submod-link-list">
24356 {% for sub in entry.submodule_links %}
24357 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
24358 {% endfor %}
24359 </div>
24360 </details>
24361 {% endif %}
24362 </td>
24363 </tr>
24364 {% endfor %}
24365 </tbody>
24366 </table>
24367 </div>
24368 <div class="pagination">
24369 <span class="pagination-info" id="pagination-info"></span>
24370 <div class="pagination-btns" id="pagination-btns"></div>
24371 <div class="flex-row">
24372 <span class="per-page-label">Show</span>
24373 <select class="per-page" id="per-page-sel">
24374 <option value="10">10 per page</option>
24375 <option value="25" selected>25 per page</option>
24376 <option value="50">50 per page</option>
24377 <option value="100">100 per page</option>
24378 </select>
24379 <span class="per-page-label" id="page-range-label"></span>
24380 </div>
24381 </div>
24382 {% endif %}
24383 </section>
24384 </div>
24385
24386 <footer class="site-footer">
24387 local code analysis - metrics, history and reports
24388 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24389 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24390 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24391 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24392 · <a href="/api-docs" rel="noopener">REST API</a>
24393 </footer>
24394
24395 <script nonce="{{ csp_nonce }}">
24396 (function () {
24397 // ── Theme ──────────────────────────────────────────────────────────────
24398 var storageKey = 'oxide-sloc-theme';
24399 var body = document.body;
24400 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
24401 var toggle = document.getElementById('theme-toggle');
24402 if (toggle) toggle.addEventListener('click', function () {
24403 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
24404 body.classList.toggle('dark-theme', next === 'dark');
24405 try { localStorage.setItem(storageKey, next); } catch(e) {}
24406 });
24407
24408 // ── State ─────────────────────────────────────────────────────────────
24409 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
24410 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
24411 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
24412
24413 // Aggregate stats from first (most recent) row
24414 if (allRows.length) {
24415 var first = allRows[0];
24416 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();}
24417 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>':'');}
24418 setChipVal('agg-code', first.dataset.code);
24419 setChipVal('agg-files', first.dataset.files);
24420 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
24421 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
24422 }
24423
24424 // ── Branch filter population ──────────────────────────────────────────
24425 (function() {
24426 var branches = {};
24427 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
24428 var sel = document.getElementById('branch-filter');
24429 if (sel) Object.keys(branches).sort().forEach(function(b) {
24430 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
24431 });
24432 })();
24433
24434 // ── Filter ────────────────────────────────────────────────────────────
24435 function getFilteredRows() {
24436 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
24437 var branch = ((document.getElementById('branch-filter') || {}).value || '');
24438 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
24439 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
24440 if (branch && (r.dataset.branch || '') !== branch) return false;
24441 return true;
24442 });
24443 }
24444
24445 // ── Pagination ────────────────────────────────────────────────────────
24446 function renderPage() {
24447 var filtered = getFilteredRows();
24448 var total = filtered.length;
24449 var totalPages = Math.max(1, Math.ceil(total / perPage));
24450 currentPage = Math.min(currentPage, totalPages);
24451 var start = (currentPage - 1) * perPage;
24452 var end = Math.min(start + perPage, total);
24453 var shown = {};
24454 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
24455 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
24456 r.style.display = shown[r.dataset.run] ? '' : 'none';
24457 });
24458 var rl = document.getElementById('page-range-label');
24459 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
24460 var info = document.getElementById('pagination-info');
24461 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
24462 var btns = document.getElementById('pagination-btns');
24463 if (!btns) return;
24464 btns.innerHTML = '';
24465 function makeBtn(lbl, pg, active, disabled) {
24466 var b = document.createElement('button');
24467 b.className = 'pg-btn' + (active ? ' active' : '');
24468 b.textContent = lbl; b.disabled = disabled;
24469 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
24470 return b;
24471 }
24472 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
24473 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
24474 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
24475 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
24476 }
24477
24478 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
24479 window.applyFilters = function() { currentPage = 1; renderPage(); };
24480
24481 // ── Sorting ───────────────────────────────────────────────────────────
24482 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
24483 function doSort(col, type, order) {
24484 var tbody = document.getElementById('history-tbody');
24485 if (!tbody) return;
24486 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24487 rows.sort(function(a, b) {
24488 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
24489 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
24490 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
24491 return va < vb ? 1 : va > vb ? -1 : 0;
24492 });
24493 rows.forEach(function(r) { tbody.appendChild(r); });
24494 currentPage = 1; renderPage();
24495 }
24496 sortHeaders.forEach(function(th) {
24497 th.addEventListener('click', function(e) {
24498 if (e.target.classList.contains('col-resize-handle')) return;
24499 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
24500 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
24501 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24502 th.classList.add('sort-' + sortOrder);
24503 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
24504 doSort(col, type, sortOrder);
24505 });
24506 });
24507
24508 // ── Column resize ─────────────────────────────────────────────────────
24509 (function() {
24510 var table = document.getElementById('history-table');
24511 if (!table) return;
24512 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
24513 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
24514 ths.forEach(function(th, i) {
24515 var handle = th.querySelector('.col-resize-handle');
24516 if (!handle || !cols[i]) return;
24517 var startX, startW;
24518 handle.addEventListener('mousedown', function(e) {
24519 e.stopPropagation(); e.preventDefault();
24520 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
24521 handle.classList.add('dragging');
24522 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
24523 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
24524 document.addEventListener('mousemove', onMove);
24525 document.addEventListener('mouseup', onUp);
24526 });
24527 });
24528 })();
24529
24530 // ── Reset view ────────────────────────────────────────────────────────
24531 window.resetView = function() {
24532 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
24533 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
24534 sortCol = null; sortOrder = 'asc';
24535 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
24536 var tbody = document.getElementById('history-tbody');
24537 if (tbody) {
24538 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
24539 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
24540 rows.forEach(function(r) { tbody.appendChild(r); });
24541 }
24542 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
24543 var table = document.getElementById('history-table');
24544 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
24545 currentPage = 1; renderPage();
24546 };
24547
24548 renderPage();
24549
24550 // ── Export helpers ────────────────────────────────────────────────────
24551 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
24552 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
24553 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);}
24554 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;');}
24555 function slocXlsx(fname,sheet,hdrs,rows){
24556 var enc=new TextEncoder();
24557 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;}
24558 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;}
24559 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
24560 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
24561 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24562 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;}
24563 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];}
24564 var rx='<row r="1">';
24565 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
24566 rx+='</row>';
24567 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>';});
24568 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
24569 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>';
24570 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>';
24571 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>';
24572 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>',
24573 '_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>',
24574 '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>',
24575 '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>',
24576 'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
24577 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'];
24578 var zparts=[],zcds=[],zoff=0,znf=0;
24579 order.forEach(function(name){
24580 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
24581 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]);
24582 var entry=new Uint8Array(lha.length+nb.length+sz);
24583 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
24584 zparts.push(entry);
24585 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));
24586 var cde=new Uint8Array(cda.length+nb.length);
24587 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
24588 zcds.push(cde);zoff+=entry.length;znf++;
24589 });
24590 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
24591 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]);
24592 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
24593 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
24594 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
24595 zout.set(new Uint8Array(ea),zpos);
24596 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
24597 }
24598
24599 var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
24600 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;}
24601 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
24602 window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
24603
24604 var csvBtn = document.getElementById('export-csv-btn');
24605 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
24606 var xlsBtn = document.getElementById('export-xls-btn');
24607 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
24608
24609 // ── Remaining CSP-safe event bindings ────────────────────────────────
24610 (function wireEvents() {
24611 var el;
24612 el = document.getElementById('reset-view-btn');
24613 if (el) el.addEventListener('click', window.resetView);
24614 el = document.getElementById('project-filter');
24615 if (el) el.addEventListener('input', window.applyFilters);
24616 el = document.getElementById('branch-filter');
24617 if (el) el.addEventListener('change', window.applyFilters);
24618 el = document.getElementById('per-page-sel');
24619 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
24620 el = document.getElementById('add-watched-btn');
24621 if (el) el.addEventListener('click', function() {
24622 fetch('/pick-directory?kind=reports')
24623 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
24624 .then(function(data) {
24625 if (!data.cancelled && data.selected_path) {
24626 var form = document.createElement('form');
24627 form.method = 'POST';
24628 form.action = '/watched-dirs/add';
24629 var ri = document.createElement('input');
24630 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
24631 var fi = document.createElement('input');
24632 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
24633 form.appendChild(ri); form.appendChild(fi);
24634 document.body.appendChild(form);
24635 form.submit();
24636 }
24637 })
24638 .catch(function(e) { alert('Could not open folder picker: ' + e); });
24639 });
24640 })();
24641
24642 (function randomizeWatermarks() {
24643 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
24644 if (!wms.length) return;
24645 var placed = [];
24646 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;}
24647 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];}
24648 var half=Math.floor(wms.length/2);
24649 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;});
24650 })();
24651
24652 (function spawnCodeParticles() {
24653 var container = document.getElementById('code-particles');
24654 if (!container) return;
24655 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'];
24656 for (var i = 0; i < 38; i++) {
24657 (function(idx) {
24658 var el = document.createElement('span');
24659 el.className = 'code-particle';
24660 el.textContent = snippets[idx % snippets.length];
24661 var left = Math.random() * 94 + 2;
24662 var top = Math.random() * 88 + 6;
24663 var dur = (Math.random() * 10 + 9).toFixed(1);
24664 var delay = (Math.random() * 18).toFixed(1);
24665 var rot = (Math.random() * 26 - 13).toFixed(1);
24666 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24667 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';
24668 container.appendChild(el);
24669 })(i);
24670 }
24671 })();
24672 })();
24673 </script>
24674 <script nonce="{{ csp_nonce }}">
24675 (function(){
24676 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'}];
24677 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);});}
24678 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24679 function init(){
24680 var btn=document.getElementById('settings-btn');if(!btn)return;
24681 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24682 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>';
24683 document.body.appendChild(m);
24684 var g=document.getElementById('scheme-grid');
24685 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);});
24686 var cl=document.getElementById('settings-close');
24687 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);
24688 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');});
24689 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24690 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24691 }
24692 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24693 }());
24694 </script>
24695 <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>
24696</body>
24697</html>
24698"##,
24699 ext = "html"
24700)]
24701struct HistoryTemplate {
24702 version: &'static str,
24703 entries: Vec<HistoryEntryRow>,
24704 total_scans: usize,
24705 linked_count: usize,
24706 browse_error: Option<String>,
24707 watched_dirs: Vec<String>,
24708 csp_nonce: String,
24709 server_mode: bool,
24710}
24711
24712#[derive(Template)]
24715#[template(
24716 source = r##"
24717<!doctype html>
24718<html lang="en">
24719<head>
24720 <meta charset="utf-8">
24721 <meta name="viewport" content="width=device-width, initial-scale=1">
24722 <title>OxideSLOC | Compare Scans</title>
24723 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24724 <style nonce="{{ csp_nonce }}">
24725 :root {
24726 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
24727 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24728 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
24729 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24730 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
24731 }
24732 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24733 *{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;}
24734 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24735 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24736 .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);}
24737 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24738 .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));}
24739 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24740 .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;}
24741 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24742 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24743 @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; } }
24744 .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;}
24745 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24746 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24747 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
24748 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
24749 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
24750 .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;}
24751 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
24752 .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);}
24753 .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;}
24754 .settings-close:hover{color:var(--text);background:var(--surface-2);}
24755 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
24756 .settings-modal-body{padding:14px 16px 16px;}
24757 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
24758 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
24759 .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;}
24760 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
24761 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
24762 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
24763 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
24764 .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;}
24765 .tz-select:focus{border-color:var(--oxide);}
24766 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
24767 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
24768 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
24769 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
24770 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
24771 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
24772 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
24773 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
24774 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
24775 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
24776 .per-page-label{font-size:13px;color:var(--muted);}
24777 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;}
24778 .filter-input{min-width:180px;cursor:text;}
24779 .table-wrap{width:100%;overflow-x:auto;}
24780 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
24781 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;}
24782 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
24783 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
24784 #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;}
24785 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
24786 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
24787 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
24788 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
24789 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
24790 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
24791 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
24792 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
24793 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
24794 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
24795 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
24796 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
24797 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24798 tr:last-child td{border-bottom:none;}
24799 tr.selected td{background:var(--sel-bg);}
24800 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
24801 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
24802 tr{cursor:pointer;}
24803 tr.row-locked{opacity:.35;cursor:not-allowed;}
24804 tr.row-locked td{pointer-events:none;}
24805 .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;}
24806 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
24807 .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;}
24808 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
24809 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
24810 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
24811 .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);}
24812 .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);}
24813 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
24814 .metric-num{font-weight:700;color:var(--text);}
24815 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
24816 .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;}
24817 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
24818 .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;}
24819 .btn:hover{background:var(--line);}
24820 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
24821 .btn.primary:hover{opacity:.9;}
24822 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
24823 .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;}
24824 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
24825 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
24826 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
24827 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
24828 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
24829 .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;}
24830 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
24831 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
24832 .watched-chip-rm:hover{color:var(--oxide);}
24833 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
24834 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
24835 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
24836 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
24837 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
24838 .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;}
24839 .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;}
24840 .btn-back:hover{background:var(--line);}
24841 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
24842 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
24843 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
24844 .pagination-info{font-size:13px;color:var(--muted);}
24845 .pagination-btns{display:flex;gap:6px;}
24846 .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;}
24847 .pg-btn:hover:not(:disabled){background:var(--line);}
24848 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
24849 .pg-btn:disabled{opacity:.35;cursor:default;}
24850 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
24851 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24852 .site-footer a{color:var(--muted);}
24853 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
24854 .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;}
24855 .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;}
24856 .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;}
24857 @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));}}
24858 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
24859 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
24860 .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;}
24861 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
24862 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
24863 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
24864 .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);}
24865 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
24866 .stat-chip:hover .stat-chip-tip{opacity:1;}
24867 .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;}
24868 .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;}
24869 .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%;}
24870 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
24871 .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;}
24872 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
24873 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
24874 .hidden{display:none!important;}
24875 .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%;}
24876 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
24877 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
24878 .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;}
24879 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
24880 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
24881 .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;}
24882 .scope-option:hover{background:var(--line);}
24883 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
24884 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
24885 .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;}
24886 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
24887 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
24888 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
24889 .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;}
24890 </style>
24891</head>
24892<body>
24893 <div class="background-watermarks" aria-hidden="true">
24894 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24895 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24896 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24897 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24898 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24899 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24900 </div>
24901 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24902 <div class="top-nav">
24903 <div class="top-nav-inner">
24904 <a class="brand" href="/">
24905 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
24906 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
24907 </a>
24908 <div class="nav-right">
24909 <a class="nav-pill" href="/">Home</a>
24910 <div class="nav-dropdown">
24911 <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>
24912 <div class="nav-dropdown-menu">
24913 <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>
24914 </div>
24915 </div>
24916 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
24917 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
24918 <div class="nav-dropdown">
24919 <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>
24920 <div class="nav-dropdown-menu">
24921 <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>
24922 </div>
24923 </div>
24924 <div class="server-status-wrap" id="server-status-wrap">
24925 <div class="nav-pill server-online-pill" id="server-status-pill">
24926 <span class="status-dot" id="status-dot"></span>
24927 <span id="server-status-label">Server</span>
24928 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
24929 </div>
24930 <div class="server-status-tip">
24931 OxideSLOC is running — accessible on your network.
24932 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
24933 </div>
24934 </div>
24935 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
24936 <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>
24937 </button>
24938 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
24939 <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>
24940 <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>
24941 </button>
24942 </div>
24943 </div>
24944 </div>
24945
24946 <div class="page">
24947 <div class="watched-bar">
24948 <div class="watched-bar-left">
24949 <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>
24950 <span class="watched-label">Watched Folders</span>
24951 <div class="watched-chips">
24952 {% if server_mode %}
24953 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
24954 {% else %}
24955 {% for dir in watched_dirs %}
24956 <span class="watched-chip">
24957 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
24958 <form method="POST" action="/watched-dirs/remove" style="display:contents">
24959 <input type="hidden" name="folder_path" value="{{ dir }}">
24960 <input type="hidden" name="redirect_to" value="/compare-scans">
24961 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
24962 </form>
24963 </span>
24964 {% endfor %}
24965 {% if watched_dirs.is_empty() %}
24966 <span class="watched-none">No folders watched — click Choose to add one</span>
24967 {% endif %}
24968 {% endif %}
24969 </div>
24970 </div>
24971 {% if !server_mode %}
24972 <div class="watched-bar-right">
24973 <button type="button" class="btn" id="add-watched-btn">
24974 <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>
24975 Choose
24976 </button>
24977 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
24978 <input type="hidden" name="redirect_to" value="/compare-scans">
24979 <button type="submit" class="btn">↻ Refresh</button>
24980 </form>
24981 </div>
24982 {% endif %}
24983 </div>
24984 {% if total_scans > 0 %}
24985 <div class="summary-strip">
24986 <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>
24987 <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>
24988 <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>
24989 <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>
24990 </div>
24991 {% endif %}
24992 <section class="panel">
24993 <div class="panel-header">
24994 <div>
24995 <h1>Compare Scans</h1>
24996 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
24997 </div>
24998 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
24999 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
25000 <button class="btn primary" id="compare-btn" disabled>
25001 <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>
25002 Compare <span class="sel-count" id="sel-count">0</span> Selected
25003 </button>
25004 </div>
25005 </div>
25006 </div>
25007
25008 {% if entries.is_empty() %}
25009 <div class="empty-state">
25010 <strong>No scans yet</strong>
25011 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.
25012 </div>
25013 {% else %}
25014 <div class="filter-row">
25015 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
25016 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
25017 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
25018 </div>
25019 <div class="scope-panel hidden" id="scope-panel">
25020 <div class="scope-panel-label">
25021 <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>
25022 Compare scope — choose what to include
25023 </div>
25024 <div class="scope-options" id="scope-options"></div>
25025 </div>
25026 {% if total_scans > 0 %}
25027 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
25028 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
25029 <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>
25030 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
25031 </div>
25032 </div>
25033 {% endif %}
25034 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
25035 <span class="compare-all-label">
25036 <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>
25037 Quick Compare All
25038 </span>
25039 </div>
25040 <div class="table-wrap">
25041 <table id="compare-table">
25042 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
25043 <thead>
25044 <tr id="compare-thead">
25045 <th><div class="col-resize-handle"></div></th>
25046 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25047 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25048 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
25049 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25050 <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>
25051 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25052 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25053 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25054 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
25055 <th>Submodules<div class="col-resize-handle"></div></th>
25056 </tr>
25057 </thead>
25058 <tbody id="compare-tbody">
25059 {% for entry in entries %}
25060 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
25061 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
25062 data-project="{{ entry.project_label }}"
25063 data-files="{{ entry.files_analyzed }}"
25064 data-code="{{ entry.code_lines }}"
25065 data-comments="{{ entry.comment_lines }}"
25066 data-blank="{{ entry.blank_lines }}"
25067 data-branch="{{ entry.git_branch }}"
25068 data-commit="{{ entry.git_commit }}"
25069 data-submodules="{{ entry.submodule_names_csv }}">
25070 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
25071 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
25072 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
25073 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
25074 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
25075 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
25076 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
25077 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
25078 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
25079 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
25080 <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>
25081 </tr>
25082 {% endfor %}
25083 </tbody>
25084 </table>
25085 </div>
25086 <div class="pagination">
25087 <span class="pagination-info" id="pagination-info"></span>
25088 <div class="pagination-btns" id="pagination-btns"></div>
25089 <div class="flex-row">
25090 <span class="per-page-label">Show</span>
25091 <select class="per-page" id="per-page-sel">
25092 <option value="10">10 per page</option>
25093 <option value="25" selected>25 per page</option>
25094 <option value="50">50 per page</option>
25095 <option value="100">100 per page</option>
25096 </select>
25097 <span class="per-page-label" id="page-range-label"></span>
25098 </div>
25099 </div>
25100 {% endif %}
25101 </section>
25102 </div>
25103
25104 <footer class="site-footer">
25105 local code analysis - metrics, history and reports
25106 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25107 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25108 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25109 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25110 · <a href="/api-docs" rel="noopener">REST API</a>
25111 </footer>
25112
25113 <script nonce="{{ csp_nonce }}">
25114 (function () {
25115 // ── Theme ──────────────────────────────────────────────────────────────
25116 var storageKey = 'oxide-sloc-theme';
25117 var body = document.body;
25118 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25119 var toggle = document.getElementById('theme-toggle');
25120 if (toggle) toggle.addEventListener('click', function () {
25121 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25122 body.classList.toggle('dark-theme', next === 'dark');
25123 try { localStorage.setItem(storageKey, next); } catch(e) {}
25124 });
25125
25126 // ── State ─────────────────────────────────────────────────────────────
25127 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
25128 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
25129 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
25130 window._allCompareRows = allRows;
25131
25132 // ── Stat chips ────────────────────────────────────────────────────────
25133 (function() {
25134 var projects = {}, latestTs = '', latestRow = null;
25135 allRows.forEach(function(r) {
25136 var p = r.dataset.project || ''; if (p) projects[p] = true;
25137 var ts = r.dataset.timestamp || '';
25138 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
25139 });
25140 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();}
25141 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>':'');}
25142 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
25143 if (latestRow) {
25144 setChipVal('agg-code', latestRow.dataset.code);
25145 setChipVal('agg-files', latestRow.dataset.files);
25146 }
25147 })();
25148
25149 // ── Branch filter population ──────────────────────────────────────────
25150 (function() {
25151 var branches = {};
25152 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
25153 var sel = document.getElementById('branch-filter');
25154 if (sel) Object.keys(branches).sort().forEach(function(b) {
25155 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
25156 });
25157 })();
25158
25159 // ── Filter ────────────────────────────────────────────────────────────
25160 function getFilteredRows() {
25161 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
25162 var branch = ((document.getElementById('branch-filter') || {}).value || '');
25163 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
25164 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
25165 if (branch && (r.dataset.branch || '') !== branch) return false;
25166 return true;
25167 });
25168 }
25169
25170 // ── Pagination ────────────────────────────────────────────────────────
25171 function renderPage() {
25172 var filtered = getFilteredRows();
25173 var total = filtered.length;
25174 var totalPages = Math.max(1, Math.ceil(total / perPage));
25175 currentPage = Math.min(currentPage, totalPages);
25176 var start = (currentPage - 1) * perPage;
25177 var end = Math.min(start + perPage, total);
25178 var shown = {};
25179 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
25180 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
25181 r.style.display = shown[r.dataset.run] ? '' : 'none';
25182 });
25183 var rl = document.getElementById('page-range-label');
25184 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
25185 var info = document.getElementById('pagination-info');
25186 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
25187 var btns = document.getElementById('pagination-btns');
25188 if (!btns) return;
25189 btns.innerHTML = '';
25190 function makeBtn(lbl, pg, active, disabled) {
25191 var b = document.createElement('button');
25192 b.className = 'pg-btn' + (active ? ' active' : '');
25193 b.textContent = lbl; b.disabled = disabled;
25194 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
25195 return b;
25196 }
25197 btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
25198 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
25199 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
25200 btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
25201 }
25202
25203 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
25204 window.applyFilters = function() { currentPage = 1; renderPage(); };
25205
25206 // ── Sorting ───────────────────────────────────────────────────────────
25207 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
25208 function doSort(col, type, order) {
25209 var tbody = document.getElementById('compare-tbody');
25210 if (!tbody) return;
25211 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25212 rows.sort(function(a, b) {
25213 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
25214 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
25215 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
25216 return va < vb ? 1 : va > vb ? -1 : 0;
25217 });
25218 rows.forEach(function(r) { tbody.appendChild(r); });
25219 currentPage = 1; renderPage();
25220 }
25221 sortHeaders.forEach(function(th) {
25222 th.addEventListener('click', function(e) {
25223 if (e.target.classList.contains('col-resize-handle')) return;
25224 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
25225 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; 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 th.classList.add('sort-' + sortOrder);
25228 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
25229 doSort(col, type, sortOrder);
25230 });
25231 });
25232
25233 // Apply default sort (timestamp desc) on initial load
25234 (function() {
25235 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
25236 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
25237 })();
25238
25239 // ── Column resize ─────────────────────────────────────────────────────
25240 (function() {
25241 var table = document.getElementById('compare-table');
25242 if (!table) return;
25243 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
25244 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
25245 ths.forEach(function(th, i) {
25246 var handle = th.querySelector('.col-resize-handle');
25247 if (!handle || !cols[i]) return;
25248 var startX, startW;
25249 handle.addEventListener('mousedown', function(e) {
25250 e.stopPropagation(); e.preventDefault();
25251 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
25252 handle.classList.add('dragging');
25253 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
25254 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
25255 document.addEventListener('mousemove', onMove);
25256 document.addEventListener('mouseup', onUp);
25257 });
25258 });
25259 })();
25260
25261 // ── Reset view ────────────────────────────────────────────────────────
25262 window.resetView = function() {
25263 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
25264 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
25265 sortCol = null; sortOrder = 'asc';
25266 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
25267 var tbody = document.getElementById('compare-tbody');
25268 if (tbody) {
25269 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
25270 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
25271 rows.forEach(function(r) { tbody.appendChild(r); });
25272 }
25273 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
25274 var table = document.getElementById('compare-table');
25275 currentPage = 1; renderPage();
25276 currentPage = 1; renderPage();
25277 };
25278
25279 renderPage();
25280 buildCompareAllBar();
25281
25282 // ── Row selection state ───────────────────────────────────────────────
25283 var selected = [];
25284 var lockedProject = null; // project label of first selected scan
25285
25286 function updateCompareBtn() {
25287 var btn = document.getElementById('compare-btn');
25288 var cnt = document.getElementById('sel-count');
25289 if (!btn) return;
25290 btn.disabled = selected.length < 2;
25291 if (cnt) cnt.textContent = selected.length;
25292 }
25293
25294 function applyProjectLock() {
25295 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25296 allRows.forEach(function(r) {
25297 if (lockedProject === null) {
25298 r.classList.remove('row-locked');
25299 } else {
25300 var proj = r.dataset.project || '';
25301 if (proj !== lockedProject) {
25302 r.classList.add('row-locked');
25303 } else {
25304 r.classList.remove('row-locked');
25305 }
25306 }
25307 });
25308 }
25309
25310 function toggleRow(row) {
25311 if (row.classList.contains('row-locked')) return;
25312 var vid = row.dataset.vid || row.dataset.run;
25313 var idx = selected.indexOf(vid);
25314 if (idx >= 0) {
25315 selected.splice(idx, 1);
25316 row.classList.remove('selected');
25317 var b = document.getElementById('badge-' + vid);
25318 if (b) b.textContent = '';
25319 // Release project lock if nothing selected
25320 if (selected.length === 0) lockedProject = null;
25321 } else {
25322 // Set project lock on first selection
25323 if (selected.length === 0) lockedProject = row.dataset.project || null;
25324 selected.push(vid);
25325 row.classList.add('selected');
25326 }
25327 selected.forEach(function(v, i) {
25328 var b = document.getElementById('badge-' + v);
25329 if (b) b.textContent = i + 1;
25330 });
25331 applyProjectLock();
25332 updateCompareBtn();
25333 buildScopePanel();
25334 }
25335
25336 // ── Compare-All bar ───────────────────────────────────────────────────
25337 function buildCompareAllBar() {
25338 var bar = document.getElementById('compare-all-bar');
25339 if (!bar) return;
25340 // Group all rows by project label.
25341 var groups = {};
25342 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25343 // Use all rows from the source data (not just visible).
25344 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
25345 // We need ALL rows across all pages, not just the rendered ones.
25346 // Use the underlying allRows array that the pagination JS also uses.
25347 var sourceRows = window._allCompareRows || allRowsAll;
25348 sourceRows.forEach(function(r) {
25349 var proj = r.dataset.project || '';
25350 var vid = r.dataset.vid || r.dataset.run || '';
25351 if (!proj || !vid) return;
25352 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
25353 groups[proj].ids.push(vid);
25354 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
25355 });
25356 // Build buttons for each project with >= 2 scans.
25357 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
25358 if (!keys.length) { bar.style.display = 'none'; return; }
25359 bar.style.display = 'flex';
25360 // Remove old buttons (keep label).
25361 var oldBtns = bar.querySelectorAll('.compare-all-btn');
25362 oldBtns.forEach(function(b) { b.remove(); });
25363 keys.sort();
25364 keys.forEach(function(proj) {
25365 var g = groups[proj];
25366 var btn = document.createElement('button');
25367 btn.className = 'compare-all-btn';
25368 btn.type = 'button';
25369 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
25370 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
25371 btn.addEventListener('click', function() {
25372 // Sort ids by timestamp (ascending).
25373 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
25374 pairs.sort(function(a, b) { return a.ts - b.ts; });
25375 var sorted = pairs.map(function(p) { return p.id; });
25376 if (sorted.length === 2) {
25377 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
25378 } else {
25379 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
25380 }
25381 });
25382 bar.appendChild(btn);
25383 });
25384 }
25385
25386 // ── Scope panel ───────────────────────────────────────────────────────
25387 var selectedScope = 'all';
25388
25389 function buildScopePanel() {
25390 var panel = document.getElementById('scope-panel');
25391 var opts = document.getElementById('scope-options');
25392 if (!panel || !opts) return;
25393 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25394
25395 // Collect union of submodules from all selected rows.
25396 var allSubs = {};
25397 selected.forEach(function(vid) {
25398 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
25399 if (!row) return;
25400 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
25401 });
25402 var subList = Object.keys(allSubs).sort();
25403 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
25404
25405 panel.classList.remove('hidden');
25406 opts.innerHTML = '';
25407
25408 function makeOption(value, label, title) {
25409 var div = document.createElement('div');
25410 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
25411 div.dataset.scopeValue = value;
25412 if (title) div.title = title;
25413 var radio = document.createElement('span');
25414 radio.className = 'scope-option-radio';
25415 var lbl = document.createElement('span');
25416 lbl.textContent = label;
25417 div.appendChild(radio);
25418 div.appendChild(lbl);
25419 div.addEventListener('click', function() {
25420 selectedScope = value;
25421 opts.querySelectorAll('.scope-option').forEach(function(o) {
25422 o.classList.toggle('selected', o.dataset.scopeValue === value);
25423 });
25424 });
25425 return div;
25426 }
25427
25428 opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
25429 var sep = document.createElement('span');
25430 sep.className = 'scope-option-sep';
25431 opts.appendChild(sep);
25432 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
25433 subList.forEach(function(s) {
25434 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
25435 });
25436 }
25437
25438 function doCompare() {
25439 if (selected.length < 2) return;
25440 if (selected.length === 2) {
25441 // Two-scan delta (existing flow with scope support).
25442 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
25443 if (selectedScope === 'super') url += '&scope=super';
25444 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25445 window.location.href = url;
25446 } else {
25447 // Multi-scan timeline (N >= 3) — pass scope params too.
25448 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
25449 if (selectedScope === 'super') url += '&scope=super';
25450 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
25451 window.location.href = url;
25452 }
25453 }
25454
25455 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
25456 var cbtn = document.getElementById('compare-btn');
25457 if (cbtn) cbtn.addEventListener('click', doCompare);
25458 var pfEl = document.getElementById('project-filter');
25459 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
25460 var bfEl = document.getElementById('branch-filter');
25461 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
25462 var rvBtn = document.getElementById('reset-view-btn');
25463 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
25464 var ppSel = document.getElementById('per-page-sel');
25465 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
25466
25467 var cmpTbody = document.getElementById('compare-tbody');
25468 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
25469 var row = e.target.closest('.compare-row');
25470 if (row) toggleRow(row);
25471 });
25472
25473 (function randomizeWatermarks() {
25474 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25475 if (!wms.length) return;
25476 var placed = [];
25477 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;}
25478 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];}
25479 var half=Math.floor(wms.length/2);
25480 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;});
25481 })();
25482
25483 (function spawnCodeParticles() {
25484 var container = document.getElementById('code-particles');
25485 if (!container) return;
25486 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'];
25487 for (var i = 0; i < 38; i++) {
25488 (function(idx) {
25489 var el = document.createElement('span');
25490 el.className = 'code-particle';
25491 el.textContent = snippets[idx % snippets.length];
25492 var left = Math.random() * 94 + 2;
25493 var top = Math.random() * 88 + 6;
25494 var dur = (Math.random() * 10 + 9).toFixed(1);
25495 var delay = (Math.random() * 18).toFixed(1);
25496 var rot = (Math.random() * 26 - 13).toFixed(1);
25497 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25498 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';
25499 container.appendChild(el);
25500 })(i);
25501 }
25502 })();
25503
25504 // ── Watched folder picker ─────────────────────────────────────────────
25505 (function() {
25506 var btn = document.getElementById('add-watched-btn');
25507 if (!btn) return;
25508 btn.addEventListener('click', function() {
25509 fetch('/pick-directory?kind=reports')
25510 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
25511 .then(function(data) {
25512 if (!data.cancelled && data.selected_path) {
25513 var form = document.createElement('form');
25514 form.method = 'POST';
25515 form.action = '/watched-dirs/add';
25516 var ri = document.createElement('input');
25517 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
25518 var fi = document.createElement('input');
25519 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
25520 form.appendChild(ri); form.appendChild(fi);
25521 document.body.appendChild(form);
25522 form.submit();
25523 }
25524 })
25525 .catch(function(e) { alert('Could not open folder picker: ' + e); });
25526 });
25527 })();
25528
25529 // ── Submodule chip truncation ─────────────────────────────────────────
25530 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
25531 var chips = cell.querySelectorAll('.submod-chip');
25532 var MAX = 4;
25533 if (chips.length <= MAX) return;
25534 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
25535 var badge = document.createElement('span');
25536 badge.className = 'submod-overflow-badge';
25537 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
25538 badge.textContent = '+' + (chips.length - MAX) + ' more';
25539 cell.appendChild(badge);
25540 cell.style.maxHeight = 'none';
25541 });
25542 })();
25543 </script>
25544 <script nonce="{{ csp_nonce }}">
25545 (function(){
25546 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'}];
25547 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);});}
25548 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25549 function init(){
25550 var btn=document.getElementById('settings-btn');if(!btn)return;
25551 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25552 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>';
25553 document.body.appendChild(m);
25554 var g=document.getElementById('scheme-grid');
25555 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);});
25556 var cl=document.getElementById('settings-close');
25557 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);
25558 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');});
25559 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25560 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25561 }
25562 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25563 }());
25564 </script>
25565 <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]';
25566 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;}
25567 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>
25568</body>
25569</html>
25570"##,
25571 ext = "html"
25572)]
25573struct CompareSelectTemplate {
25574 version: &'static str,
25575 entries: Vec<HistoryEntryRow>,
25576 total_scans: usize,
25577 watched_dirs: Vec<String>,
25578 csp_nonce: String,
25579 server_mode: bool,
25580}
25581
25582#[derive(Template)]
25585#[template(
25586 source = r##"
25587<!doctype html>
25588<html lang="en">
25589<head>
25590 <meta charset="utf-8">
25591 <meta name="viewport" content="width=device-width, initial-scale=1">
25592 <title>OxideSLOC | Scan Delta</title>
25593 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25594 <style nonce="{{ csp_nonce }}">
25595 :root {
25596 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
25597 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
25598 --nav:#283790; --nav-2:#013e6b;
25599 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
25600 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
25601 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
25602 }
25603 body.dark-theme {
25604 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
25605 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
25606 }
25607 *{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;}
25608 .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);}
25609 .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;}
25610 .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));}
25611 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25612 .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;}
25613 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
25614 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25615 @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; } }
25616 .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;}
25617 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25618 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25619 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25620 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25621 .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;}
25622 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25623 .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);}
25624 .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;}
25625 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25626 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25627 .settings-modal-body{padding:14px 16px 16px;}
25628 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25629 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25630 .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;}
25631 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25632 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25633 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25634 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25635 .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;}
25636 .tz-select:focus{border-color:var(--oxide);}
25637 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
25638 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25639 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
25640 .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;}
25641 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
25642 .hero-body{display:block;}
25643 .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;}
25644 .btn-back:hover{background:var(--line);}
25645 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
25646 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
25647 .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;}
25648 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
25649 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;}
25650 .muted{color:var(--muted);font-size:14px;}
25651 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
25652 .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;}
25653 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
25654 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
25655 .vpill-arrow{font-size:20px;color:var(--muted);}
25656 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
25657 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
25658 .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;}
25659 .delta-card.delta-card-wide{padding:22px 24px;}
25660 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
25661 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
25662 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
25663 .delta-card-from{font-size:15px;color:var(--muted);}
25664 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
25665 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
25666 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
25667 .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%;}
25668 .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;}
25669 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
25670 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
25671 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
25672 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
25673 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
25674 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
25675 .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;}
25676 .meta-card-commit:hover{color:var(--oxide);}
25677 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
25678 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
25679 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
25680 .meta-value{color:var(--text);font-size:13px;}
25681 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
25682 .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;}
25683 .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);}
25684 .delta-card:hover .dc-tip{display:block;}
25685 .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;}
25686 .export-btn:hover{background:var(--line);}
25687 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
25688 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
25689 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
25690 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
25691 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
25692 .delta-card-change.zero{color:var(--muted);background:transparent;}
25693 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
25694 .delta-card-pct.pos{color:var(--pos);}
25695 .delta-card-pct.neg{color:var(--neg);}
25696 .delta-card-pct.zero{color:var(--muted);}
25697 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
25698 .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;}
25699 .insight-card.insight-flag{border-color:var(--oxide);}
25700 .insight-card:hover .dc-tip{display:block;}
25701 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
25702 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
25703 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
25704 .insight-label.flag{color:var(--oxide);}
25705 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
25706 .insight-val.pos{color:var(--pos);}
25707 .insight-val.neg{color:var(--neg);}
25708 .insight-val.high{color:#c0392a;}
25709 .insight-val.med{color:#926000;}
25710 .insight-val.low{color:var(--pos);}
25711 body.dark-theme .insight-val.high{color:#ff6b6b;}
25712 body.dark-theme .insight-val.med{color:#f0c060;}
25713 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
25714 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
25715 .fc-row{display:flex;align-items:center;gap:8px;}
25716 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
25717 .fc-label{color:var(--muted);}
25718 .fc-modified .fc-count{color:#926000;}
25719 .fc-added .fc-count{color:var(--pos);}
25720 .fc-removed .fc-count{color:var(--neg);}
25721 .fc-unchanged .fc-count{color:var(--muted);}
25722 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
25723 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
25724 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
25725 .chip.modified{background:#fff2d8;color:#926000;}
25726 .chip.added{background:#e8f5ed;color:#1a8f47;}
25727 .chip.removed{background:#fdeaea;color:#b33b3b;}
25728 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
25729 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
25730 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
25731 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
25732 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
25733 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
25734 .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;}
25735 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
25736 .tab-btn:hover:not(.active){background:var(--line);}
25737 .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;}
25738 .btn-reset:hover{background:var(--line);}
25739 .table-wrap{width:100%;overflow-x:auto;}
25740 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
25741 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);}
25742 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
25743 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
25744 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
25745 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
25746 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
25747 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
25748 tr:last-child td{border-bottom:none;}
25749 tr:hover td{background:var(--surface-2);}
25750 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
25751 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
25752 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
25753 tr.row-added td{background:rgba(26,143,71,0.04);}
25754 tr.row-removed td{background:rgba(179,59,59,0.06);}
25755 tr.row-modified td{background:rgba(146,96,0,0.04);}
25756 tr.row-unchanged td{color:var(--muted);}
25757 tr.row-unchanged .status-badge{opacity:.65;}
25758 .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;}
25759 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
25760 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
25761 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
25762 .status-badge.modified{background:#fff2d8;color:#926000;}
25763 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
25764 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
25765 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
25766 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
25767 .delta-val{font-weight:700;}
25768 .delta-val.pos{color:var(--pos);}
25769 .delta-val.neg{color:var(--neg);}
25770 .delta-val.zero{color:var(--muted);}
25771 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
25772 .from-to strong{color:var(--text);font-weight:700;}
25773 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
25774 .from-to .ft-absent{color:var(--muted);font-weight:600;}
25775 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25776 .site-footer a{color:var(--muted);}
25777 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;}
25778 body.pdf-mode{background:#fff!important;}
25779 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
25780 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
25781 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25782 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25783 .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;}
25784 .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;}
25785 .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;}
25786 @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));}}
25787 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
25788 .path-link:hover{color:var(--oxide-2);}
25789 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
25790 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
25791 a.vpill-id:hover{color:var(--oxide);}
25792 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
25793 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
25794 .pagination-info{font-size:13px;color:var(--muted);}
25795 .pagination-btns{display:flex;gap:6px;}
25796 .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;}
25797 .pg-btn:hover:not(:disabled){background:var(--line);}
25798 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25799 .pg-btn:disabled{opacity:.35;cursor:default;}
25800 .per-page-label{font-size:13px;color:var(--muted);}
25801 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;}
25802 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25803 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
25804 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
25805 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
25806 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
25807 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
25808 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
25809 .tab-btn.tab-unchanged{color:var(--muted);}
25810 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
25811 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
25812 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
25813 .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;}
25814 .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;}
25815 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
25816 .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;}
25817 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
25818 .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;}
25819 .submod-scope-btn:hover{background:var(--line);}
25820 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25821 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
25822 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
25823 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
25824 .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
25825 body.dark-theme .ic-card{background:var(--surface-2);}
25826 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
25827 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
25828 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
25829 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
25830 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
25831 .ic-cb{cursor:pointer;transition:filter .15s;}.ic-cb:hover{filter:brightness(1.12);}
25832 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
25833 .ic-card-h2-row .ic-card-h2{margin:0;}
25834 .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;}
25835 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
25836 .chart-metric-btn:hover:not(.active){background:var(--line);}
25837 .chart-wrap{width:100%;overflow-x:auto;}
25838 #cmp-tl-svg{display:block;width:100%;}
25839 .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);}
25840 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
25841 #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;}
25842 </style>
25843</head>
25844<body>
25845 <div class="background-watermarks" aria-hidden="true">
25846 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25847 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25848 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25849 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25850 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25851 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25852 </div>
25853 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25854 <div class="top-nav">
25855 <div class="top-nav-inner">
25856 <a class="brand" href="/">
25857 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25858 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
25859 </a>
25860 <div class="nav-right">
25861 <a class="nav-pill" href="/">Home</a>
25862 <div class="nav-dropdown">
25863 <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>
25864 <div class="nav-dropdown-menu">
25865 <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>
25866 </div>
25867 </div>
25868 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25869 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25870 <div class="nav-dropdown">
25871 <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>
25872 <div class="nav-dropdown-menu">
25873 <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>
25874 </div>
25875 </div>
25876 <div class="server-status-wrap" id="server-status-wrap">
25877 <div class="nav-pill server-online-pill" id="server-status-pill">
25878 <span class="status-dot" id="status-dot"></span>
25879 <span id="server-status-label">Server</span>
25880 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25881 </div>
25882 <div class="server-status-tip">
25883 OxideSLOC is running — accessible on your network.
25884 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25885 </div>
25886 </div>
25887 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25888 <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>
25889 </button>
25890 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25891 <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>
25892 <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>
25893 </button>
25894 </div>
25895 </div>
25896 </div>
25897
25898 <div class="page">
25899 <section class="hero">
25900 <div class="hero-header">
25901 <div>
25902 <h1 class="delta-title">Scan Delta</h1>
25903 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
25904 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
25905 {% if let Some(sub) = active_submodule %}
25906 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
25907 {% else if super_scope_active %}
25908 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
25909 {% else %}
25910 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
25911 {% endif %}
25912 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
25913 </div>
25914 </div>
25915 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
25916 <a class="btn-back" href="/compare-scans">
25917 <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>
25918 Compare Scans
25919 </a>
25920 <div class="export-group" style="margin-top:12px;">
25921 <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>
25922 <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>
25923 </div>
25924 </div>
25925 </div>
25926 {% if has_any_submodule_data %}
25927 <div class="submod-scope-bar">
25928 <span class="submod-scope-label">
25929 <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>
25930 Scope:
25931 </span>
25932 <div class="submod-scope-divider"></div>
25933 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
25934 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
25935 title="All files — super-repo and all submodules combined">Full scan</a>
25936 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
25937 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
25938 title="Only files that are not part of any submodule">Super-repo only</a>
25939 {% for sub in submodule_options %}
25940 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
25941 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
25942 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
25943 {% endfor %}
25944 </div>
25945 {% endif %}
25946 <div class="hero-body">
25947 <div class="meta-strip">
25948 <div class="delta-card delta-card-meta">
25949 <div class="meta-card-header">
25950 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
25951 <div class="meta-card-project-col">
25952 <div class="meta-card-project">{{ project_name }}</div>
25953 {% if has_any_submodule_data %}
25954 {% if let Some(sub) = active_submodule %}
25955 <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>
25956 {% else if super_scope_active %}
25957 <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>
25958 {% else %}
25959 <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>
25960 {% endif %}
25961 {% endif %}
25962 </div>
25963 </div>
25964 {% if !baseline_git_commit.is_empty() %}
25965 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
25966 {% else %}
25967 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
25968 {% endif %}
25969 <div class="meta-card-rows">
25970 <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>
25971 <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>
25972 <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>
25973 <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>
25974 {% if let Some(tags) = baseline_git_tags %}
25975 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
25976 {% endif %}
25977 </div>
25978 </div>
25979 <div class="delta-card delta-card-meta">
25980 <div class="meta-card-header">
25981 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
25982 <div class="meta-card-project-col">
25983 <div class="meta-card-project">{{ project_name }}</div>
25984 {% if has_any_submodule_data %}
25985 {% if let Some(sub) = active_submodule %}
25986 <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>
25987 {% else if super_scope_active %}
25988 <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>
25989 {% else %}
25990 <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>
25991 {% endif %}
25992 {% endif %}
25993 </div>
25994 </div>
25995 {% if !current_git_commit.is_empty() %}
25996 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
25997 {% else %}
25998 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
25999 {% endif %}
26000 <div class="meta-card-rows">
26001 <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>
26002 <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>
26003 <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>
26004 <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>
26005 {% if let Some(tags) = current_git_tags %}
26006 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
26007 {% endif %}
26008 </div>
26009 </div>
26010 </div>
26011 <div class="delta-strip">
26012 <div class="delta-card">
26013 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
26014 <div class="delta-card-label">Code lines</div>
26015 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
26016 <div class="delta-card-to">{{ current_code_fmt }}</div>
26017 {% 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>
26018 {% 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>
26019 {% else %}<div class="delta-card-pct zero">±0%</div>
26020 {% endif %}
26021 </div>
26022 <div class="delta-card">
26023 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
26024 <div class="delta-card-label">Files analyzed</div>
26025 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
26026 <div class="delta-card-to">{{ current_files_fmt }}</div>
26027 {% 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>
26028 {% 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>
26029 {% else %}<div class="delta-card-pct zero">±0%</div>
26030 {% endif %}
26031 </div>
26032 <div class="delta-card">
26033 <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>
26034 <div class="delta-card-label">Comment lines</div>
26035 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
26036 <div class="delta-card-to">{{ current_comments_fmt }}</div>
26037 {% 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>
26038 {% 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>
26039 {% else %}<div class="delta-card-pct zero">±0%</div>
26040 {% endif %}
26041 </div>
26042 {{ coverage_delta_card|safe }}
26043 <div class="delta-card delta-card-wide">
26044 <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>
26045 <div class="delta-card-label">File changes</div>
26046 <div class="file-changes-grid">
26047 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
26048 <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
26049 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
26050 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
26051 </div>
26052 </div>
26053 </div>
26054 <div class="insights-panel">
26055 <div class="insight-card">
26056 <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>
26057 <div class="insight-label">Lines Added</div>
26058 <div class="insight-val pos">+{{ code_lines_added }}</div>
26059 <div class="insight-sub">New or grown source lines</div>
26060 </div>
26061 <div class="insight-card">
26062 <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>
26063 <div class="insight-label">Lines Removed</div>
26064 <div class="insight-val neg">−{{ code_lines_removed }}</div>
26065 <div class="insight-sub">Deleted or shrunk source lines</div>
26066 </div>
26067 <div class="insight-card">
26068 <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>
26069 <div class="insight-label">Churn Rate</div>
26070 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
26071 <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>
26072 </div>
26073 {% if scope_flag %}
26074 <div class="insight-card insight-flag">
26075 <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>
26076 <div class="insight-label flag">Scope Signal</div>
26077 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
26078 <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>
26079 </div>
26080 {% endif %}
26081 </div>
26082 </div>
26083 </section>
26084
26085 <section class="panel" id="inline-charts-section">
26086 <div class="panel-title">Scan Delta Charts</div>
26087 <div class="ic-grid">
26088 <div class="ic-card" style="grid-column:span 2">
26089 <div class="ic-card-h2-row">
26090 <span class="ic-card-h2">Timeline</span>
26091 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
26092 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
26093 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
26094 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
26095 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
26096 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
26097 </div>
26098 </div>
26099 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
26100 </div>
26101 <div class="ic-card">
26102 <div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
26103 <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>
26104 <div id="ic-c1"></div>
26105 </div>
26106 <div class="ic-card" id="ic-lang-card">
26107 <div class="ic-card-h2">Language Code Delta</div>
26108 <div id="ic-c3"></div>
26109 </div>
26110 <div class="ic-card">
26111 <div class="ic-card-h2">Delta by Metric</div>
26112 <div id="ic-c2"></div>
26113 </div>
26114 <div class="ic-card">
26115 <div class="ic-card-h2">File Change Distribution</div>
26116 <div id="ic-c4"></div>
26117 </div>
26118 </div>
26119 </section>
26120
26121 <section class="panel">
26122 <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>
26123 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
26124 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
26125 <button class="tab-btn tab-all active" data-filter="all">All ({{ files_modified + files_added + files_removed + files_unchanged }})</button>
26126 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
26127 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
26128 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
26129 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
26130 </div>
26131 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
26132 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
26133 <div class="export-group">
26134 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
26135 <button type="button" class="export-btn" id="delta-csv-btn">
26136 <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>
26137 CSV
26138 </button>
26139 <button type="button" class="export-btn" id="delta-xls-btn">
26140 <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>
26141 Excel
26142 </button>
26143 </div>
26144 </div>
26145 </div>
26146
26147 <div class="table-wrap">
26148 <table id="delta-table">
26149 <colgroup>
26150 <col>
26151 <col>
26152 <col>
26153 <col>
26154 <col>
26155 <col>
26156 <col>
26157 </colgroup>
26158 <thead>
26159 <tr id="delta-thead">
26160 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26161 <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>
26162 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
26163 <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>
26164 <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>
26165 <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>
26166 <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>
26167 </tr>
26168 </thead>
26169 <tbody id="delta-tbody">
26170 {% for row in file_rows %}
26171 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
26172 data-path="{{ row.relative_path }}"
26173 data-language="{{ row.language }}"
26174 data-baseline-code="{{ row.baseline_code }}"
26175 data-current-code="{{ row.current_code }}"
26176 data-code-delta="{{ row.code_delta_str }}"
26177 data-comment-delta="{{ row.comment_delta_str }}"
26178 data-total-delta="{{ row.total_delta_str }}"
26179 data-orig-idx="">
26180 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
26181 <td class="hide-sm">{{ row.language }}</td>
26182 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
26183 <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>
26184 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
26185 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
26186 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
26187 </tr>
26188 {% endfor %}
26189 </tbody>
26190 </table>
26191 </div>
26192 <div class="pagination">
26193 <span class="pagination-info" id="pg-range-label"></span>
26194 <div class="pagination-btns" id="pg-btns"></div>
26195 <div class="flex-row">
26196 <span class="per-page-label">Show</span>
26197 <select class="per-page" id="per-page-sel">
26198 <option value="10">10 per page</option>
26199 <option value="25" selected>25 per page</option>
26200 <option value="50">50 per page</option>
26201 <option value="100">100 per page</option>
26202 </select>
26203 </div>
26204 </div>
26205 </section>
26206 </div>
26207
26208 <div id="ic-tt"></div>
26209
26210 <footer class="site-footer">
26211 local code analysis - metrics, history and reports
26212 · <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>
26213 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26214 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26215 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26216 · <a href="/api-docs" rel="noopener">REST API</a>
26217 </footer>
26218
26219 <script nonce="{{ csp_nonce }}">
26220 (function () {
26221 var storageKey = 'oxide-sloc-theme';
26222 var body = document.body;
26223 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26224 var toggle = document.getElementById('theme-toggle');
26225 if (toggle) toggle.addEventListener('click', function () {
26226 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26227 body.classList.toggle('dark-theme', next === 'dark');
26228 try { localStorage.setItem(storageKey, next); } catch(e) {}
26229 });
26230
26231 (function randomizeWatermarks() {
26232 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26233 if (!wms.length) return;
26234 var placed = [];
26235 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;}
26236 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];}
26237 var half=Math.floor(wms.length/2);
26238 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;});
26239 })();
26240
26241 (function spawnCodeParticles() {
26242 var container = document.getElementById('code-particles');
26243 if (!container) return;
26244 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'];
26245 for (var i = 0; i < 38; i++) {
26246 (function(idx) {
26247 var el = document.createElement('span');
26248 el.className = 'code-particle';
26249 el.textContent = snippets[idx % snippets.length];
26250 var left = Math.random() * 94 + 2;
26251 var top = Math.random() * 88 + 6;
26252 var dur = (Math.random() * 10 + 9).toFixed(1);
26253 var delay = (Math.random() * 18).toFixed(1);
26254 var rot = (Math.random() * 26 - 13).toFixed(1);
26255 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26256 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';
26257 container.appendChild(el);
26258 })(i);
26259 }
26260 })();
26261 })();
26262
26263 var activeStatusFilter = 'all';
26264 var deltaPerPage = 25, deltaCurrPage = 1;
26265
26266 function openFolder(path) {
26267 fetch('/open-path?path=' + encodeURIComponent(path))
26268 .then(function (r) { return r.json(); })
26269 .then(function (d) {
26270 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
26271 })
26272 .catch(function () {});
26273 }
26274
26275 function getDeltaFilteredRows() {
26276 return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
26277 return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
26278 });
26279 }
26280
26281 function renderDeltaPage() {
26282 var filtered = getDeltaFilteredRows();
26283 var total = filtered.length;
26284 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
26285 deltaCurrPage = Math.min(deltaCurrPage, totalPages);
26286 var start = (deltaCurrPage - 1) * deltaPerPage;
26287 var end = Math.min(start + deltaPerPage, total);
26288 var shownSet = {};
26289 filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
26290 Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
26291 r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
26292 });
26293 var rl = document.getElementById('pg-range-label');
26294 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total + ' files' : 'No results';
26295 var btns = document.getElementById('pg-btns');
26296 if (!btns) return;
26297 btns.innerHTML = '';
26298 if (totalPages <= 1) return;
26299 function makeBtn(lbl, pg, active, disabled) {
26300 var b = document.createElement('button');
26301 b.className = 'pg-btn' + (active ? ' active' : '');
26302 b.textContent = lbl; b.disabled = disabled;
26303 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
26304 return b;
26305 }
26306 btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
26307 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26308 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
26309 btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
26310 }
26311
26312 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
26313
26314 function filterRows(status, btn) {
26315 activeStatusFilter = status;
26316 deltaCurrPage = 1;
26317 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
26318 b.classList.remove('active');
26319 });
26320 if (btn) btn.classList.add('active');
26321 renderDeltaPage();
26322 }
26323
26324 // ── Sorting ──────────────────────────────────────────────────────────────
26325 var sortCol = null, sortOrder = 'asc';
26326 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
26327 (function() {
26328 var tbody = document.getElementById('delta-tbody');
26329 if (!tbody) return;
26330 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26331 rows.forEach(function(r, i) { r.dataset.origIdx = i; });
26332 })();
26333
26334 function parseDeltaNum(str) {
26335 if (!str || str === '\u2014') return 0;
26336 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
26337 }
26338
26339 sortHeaders.forEach(function(th) {
26340 th.addEventListener('click', function(e) {
26341 if (e.target.classList.contains('col-resize-handle')) return;
26342 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26343 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26344 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26345 th.classList.add('sort-' + sortOrder);
26346 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
26347 var tbody = document.getElementById('delta-tbody');
26348 if (!tbody) return;
26349 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26350 rows.sort(function(a, b) {
26351 var va, vb;
26352 if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
26353 else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
26354 else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
26355 else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
26356 else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26357 else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26358 else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
26359 else { va = ''; vb = ''; }
26360 if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26361 return va < vb ? 1 : va > vb ? -1 : 0;
26362 });
26363 rows.forEach(function(r) { tbody.appendChild(r); });
26364 deltaCurrPage = 1;
26365 renderDeltaPage();
26366 var activeBtn = document.querySelector('.tab-btn.active');
26367 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26368 if (activeBtn) activeBtn.classList.add('active');
26369 });
26370 });
26371
26372 // ── Column resize ─────────────────────────────────────────────────────────
26373 (function() {
26374 var table = document.getElementById('delta-table');
26375 if (!table) return;
26376 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26377 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
26378 ths.forEach(function(th, i) {
26379 var handle = th.querySelector('.col-resize-handle');
26380 if (!handle || !cols[i]) return;
26381 var startX, startW;
26382 handle.addEventListener('mousedown', function(e) {
26383 e.stopPropagation(); e.preventDefault();
26384 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26385 handle.classList.add('dragging');
26386 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26387 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26388 document.addEventListener('mousemove', onMove);
26389 document.addEventListener('mouseup', onUp);
26390 });
26391 });
26392 })();
26393
26394 // ── Reset ─────────────────────────────────────────────────────────────────
26395 window.resetDeltaTable = function() {
26396 sortCol = null; sortOrder = 'asc';
26397 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
26398 var tbody = document.getElementById('delta-tbody');
26399 if (tbody) {
26400 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
26401 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26402 rows.forEach(function(r) { tbody.appendChild(r); });
26403 }
26404 var table = document.getElementById('delta-table');
26405 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26406 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
26407 activeStatusFilter = 'all';
26408 deltaCurrPage = 1;
26409 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
26410 var allBtn = document.querySelector('.tab-btn');
26411 if (allBtn) allBtn.classList.add('active');
26412 renderDeltaPage();
26413 };
26414
26415 renderDeltaPage();
26416
26417 // Compact number formatter (shared by the delta table; charts define their own locally)
26418 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();}
26419 function fmtFull(n){return Number(n).toLocaleString();}
26420
26421 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
26422 function fmtFromTo() {
26423 var tbody = document.getElementById('delta-tbody');
26424 if (!tbody) return;
26425 tbody.querySelectorAll('.delta-row').forEach(function(row) {
26426 var status = row.dataset.status || '';
26427 var ft = row.querySelector('.from-to');
26428 if (!ft) return;
26429 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
26430 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
26431 var strongs = ft.querySelectorAll('strong');
26432 // Apply fmt() to non-absent strong values
26433 strongs.forEach(function(el) {
26434 var n = parseInt(el.textContent, 10);
26435 if (!isNaN(n)) el.textContent = fmt(n);
26436 });
26437 // Safety: force dash for genuinely absent sides
26438 if (status === 'added' && bv === 0) {
26439 var bs = ft.querySelector('strong:first-of-type');
26440 if (bs && bs.textContent === '0') {
26441 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
26442 }
26443 }
26444 if (status === 'removed' && cv === 0) {
26445 var cs = ft.querySelector('strong:last-of-type');
26446 if (cs && cs.textContent === '0') {
26447 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
26448 }
26449 }
26450 });
26451 }
26452 fmtFromTo();
26453
26454 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
26455 (function() {
26456 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
26457 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
26458 });
26459 var resetBtn = document.getElementById('delta-reset-btn');
26460 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
26461 var csvBtn = document.getElementById('delta-csv-btn');
26462 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
26463 var xlsBtn = document.getElementById('delta-xls-btn');
26464 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
26465 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
26466 function sdFetchUri(path) {
26467 return fetch(path).then(function(r){return r.blob();}).then(function(b){
26468 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
26469 }).catch(function(){return '';});
26470 }
26471 function sdInlineImgs(html, cb) {
26472 var paths=[], seen={};
26473 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
26474 if(!paths.length){cb(html);return;}
26475 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
26476 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
26477 .catch(function(){cb(html);});
26478 }
26479 function buildFullPageHtml(pdfMode) {
26480 if(pdfMode) document.body.classList.add('pdf-mode');
26481 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
26482 renderDeltaPage();
26483 var html = document.documentElement.outerHTML;
26484 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
26485 if(pdfMode) document.body.classList.remove('pdf-mode');
26486 return html;
26487 }
26488 var chartsBtn = document.getElementById('delta-charts-btn');
26489 if (chartsBtn) chartsBtn.addEventListener('click', function() {
26490 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26491 sdInlineImgs(buildFullPageHtml(false), function(html) {
26492 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26493 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26494 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26495 btn.disabled=false;btn.innerHTML=orig;
26496 });
26497 });
26498 var pageHtmlBtn = document.getElementById('page-export-html-btn');
26499 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
26500 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
26501 sdInlineImgs(buildFullPageHtml(false), function(html) {
26502 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
26503 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
26504 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
26505 btn.disabled=false;btn.innerHTML=orig;
26506 });
26507 });
26508 // PDF export — clean document-style report, not a web page screenshot
26509 function buildDeltaPdfHtml() {
26510 var sd=_sd, dr=getDeltaExportRows();
26511 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
26512 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
26513 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
26514 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
26515 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26516 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();}
26517 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
26518 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>';}
26519 var lm={};
26520 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;});
26521 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,15);
26522 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
26523 var css='body{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
26524 '.hdr{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}'+
26525 '.brand{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}'+
26526 '.title{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}'+
26527 '.proj{font-size:12px;color:#99aabb;margin-top:3px;}'+
26528 '.hr{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}'+
26529 '.body{padding:18px 24px;}'+
26530 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;}'+
26531 '.sc{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}'+
26532 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
26533 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
26534 '.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;}'+
26535 '.meta>div{flex:1 1 0;}'+
26536 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:4px;font-size:15px;}'+
26537 '.sec{margin-bottom:18px;}'+
26538 '.sh{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}'+
26539 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
26540 'th{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;}'+
26541 'td{border-bottom:1px solid #eee;padding:5px 10px;vertical-align:middle;}'+
26542 'tr:nth-child(even) td{background:#faf8f6;}'+
26543 '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:16px;}';
26544 var fileRows=dr.slice(0,200).map(function(r){
26545 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
26546 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
26547 '<td style="'+ss+'">'+esc(st)+'</td>'+
26548 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
26549 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
26550 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
26551 }).join('');
26552 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>':'';
26553 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('');
26554 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
26555 '<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Scan Delta</div><div class="proj">'+esc(projName)+'</div></div>'+
26556 '<div class="hr">'+esc(_blabel)+'<br>'+esc(_clabel)+'<br>Generated: '+esc(now)+'</div></div>'+
26557 '<div class="body">'+
26558 '<div class="sg">'+
26559 '<div class="sc"><div class="sv">'+delt(sd.cd)+'</div><div class="sl">Code Lines \u0394</div></div>'+
26560 '<div class="sc"><div class="sv">'+delt(sd.fd)+'</div><div class="sl">Files \u0394</div></div>'+
26561 '<div class="sc"><div class="sv">'+delt(sd.cmd)+'</div><div class="sl">Comment Lines \u0394</div></div>'+
26562 '<div class="sc"><div class="sv" style="color:#111">'+fmtN(tfTotal)+'</div><div class="sl">Total Files</div></div>'+
26563 '</div>'+
26564 '<div class="meta">'+
26565 '<div><div class="ml">Baseline Code</div><div class="mv">'+fullN(sd.bc)+'</div></div>'+
26566 '<div><div class="ml">Current Code</div><div class="mv">'+fullN(sd.cc)+'</div></div>'+
26567 '<div><div class="ml">Modified</div><div class="mv">'+fullN(sd.fm)+'</div></div>'+
26568 '<div><div class="ml">Added</div><div class="mv" style="color:#2a6846">+'+fullN(sd.fa)+'</div></div>'+
26569 '<div><div class="ml">Removed</div><div class="mv" style="color:#b23030">-'+fullN(sd.fr)+'</div></div>'+
26570 '<div><div class="ml">Unchanged</div><div class="mv">'+fullN(sd.fu)+'</div></div>'+
26571 '</div>'+
26572 (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>':'')+
26573 '<div class="sec"><p class="sh">File Delta ('+fmtN(dr.length)+' files)</p>'+
26574 '<table><thead><tr><th>File</th><th>Language</th><th>Status</th>'+
26575 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
26576 '</tr></thead><tbody>'+fileRows+more+'</tbody></table></div>'+
26577 '</div>'+
26578 '<div class="ftr"><span>oxide-sloc v{{ version }}</span><span>Scan Delta Report</span>'+
26579 '<span>'+esc(sd.bid)+' \u2192 '+esc(sd.cid)+'</span></div>'+
26580 '</body></html>';
26581 }
26582 function doDeltaPdf(btn) {
26583 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
26584 var html=buildDeltaPdfHtml();
26585 fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:html,filename:getExportFilename('pdf')})})
26586 .then(function(r){if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();})
26587 .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);})
26588 .catch(function(e){alert('PDF export failed: '+e.message);})
26589 .finally(function(){btn.disabled=false;btn.innerHTML=orig;});
26590 }
26591 var pdfBtn = document.getElementById('delta-pdf-btn');
26592 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
26593 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
26594 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
26595 if (location.protocol === 'file:') {
26596 [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'; } });
26597 [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'; } });
26598 }
26599 var ppSel = document.getElementById('per-page-sel');
26600 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
26601 var pathLink = document.getElementById('project-path-link');
26602 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
26603 })();
26604
26605 // ── Export helpers ────────────────────────────────────────────────────────
26606 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
26607 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26608 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);}
26609 function slocMakeXlsx(fname,sd,dr){
26610 var enc=new TextEncoder();
26611 // CRC-32 table
26612 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;}
26613 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;}
26614 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26615 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26616 // Shared string table
26617 var ss=[],si={};
26618 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
26619 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26620 // Worksheet builder — each WS() call gets its own row counter R
26621 function WS(){
26622 var R=0,buf=[];
26623 function cl(c){return String.fromCharCode(65+c);}
26624 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
26625 '<v>'+S(v)+'</v></c>';}
26626 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
26627 (st?' s="'+st+'"':'')+'>'+
26628 '<v>'+(+v)+'</v></c>';}
26629 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
26630 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26631 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
26632 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
26633 '<sheetFormatPr defaultRowHeight="15"/>'+
26634 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
26635 return{sc:sc,nc:nc,row:row,xml:xml};
26636 }
26637 // Language breakdown
26638 var lm={};
26639 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;});
26640 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
26641 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
26642 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
26643 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
26644 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26645 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26646 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):'';}
26647 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
26648 // Summary sheet
26649 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
26650 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
26651 r1(s1(0,proj,2));
26652 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
26653 r1('');
26654 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
26655 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))));
26656 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))));
26657 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))));
26658 r1('');
26659 r1(s1(0,'FILE CHANGES',8));
26660 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
26661 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
26662 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
26663 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
26664 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
26665 if(langs.length){
26666 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
26667 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
26668 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)));});
26669 }
26670 r1('');r1(s1(0,'SCAN METADATA',8));
26671 r1(s1(1,_blabel)+s1(2,_clabel));
26672 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
26673 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
26674 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"/>');
26675 // File Delta sheet
26676 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
26677 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));
26678 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)));});
26679 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
26680 // Shared strings XML
26681 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
26682 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
26683 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
26684 // XLSX file map
26685 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26686 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>',
26687 '_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>',
26688 '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>',
26689 '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>',
26690 '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>',
26691 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
26692 // ZIP packer — STORED (no compression), compatible with all XLSX readers
26693 var zparts=[],zcds=[],zoff=0,znf=0;
26694 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
26695 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
26696 ].forEach(function(name){
26697 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26698 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]);
26699 var entry=new Uint8Array(lha.length+nb.length+sz);
26700 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26701 zparts.push(entry);
26702 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));
26703 var cde=new Uint8Array(cda.length+nb.length);
26704 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26705 zcds.push(cde);zoff+=entry.length;znf++;
26706 });
26707 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26708 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]);
26709 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26710 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26711 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26712 zout.set(new Uint8Array(ea),zpos);
26713 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
26714 var xurl=URL.createObjectURL(xblob);
26715 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
26716 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
26717 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
26718 }
26719 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;');}
26720 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
26721 function getExportFilename(ext){return _exportBase+'.'+ext;}
26722
26723 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 %}};
26724 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;}
26725 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
26726 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
26727 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
26728 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
26729 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):'';}
26730 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
26731 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)]];}
26732 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
26733 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;}
26734 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
26735 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
26736
26737 // ── Chart HTML report ─────────────────────────────────────────────────────
26738 function slocChartReport(fname, sd, dr) {
26739 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
26740 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26741 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26742 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();}
26743 function px(n){return Math.round(n);}
26744 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
26745 // Language map
26746 var lm={};
26747 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;});
26748 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26749
26750 // Builds onmouse* attrs for interactive tooltip on each SVG element
26751 function barTT(label,val){
26752 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
26753 }
26754
26755 // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
26756 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'}];
26757 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26758 var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14;
26759 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
26760 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26761 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"/>';}
26762 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26763 c1mets.forEach(function(m,i){
26764 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26765 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26766 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>';
26767 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))+'/>';
26768 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>';
26769 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))+'/>';
26770 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>';
26771 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>';
26772 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>';
26773 });
26774 c1+='</svg>';
26775
26776 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
26777 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'}];
26778 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26779 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
26780 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
26781 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26782 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26783 mets.forEach(function(m,i){
26784 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
26785 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
26786 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
26787 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>';
26788 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
26789 if(bw>=52){
26790 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>';
26791 }else{
26792 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
26793 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>';
26794 }
26795 });
26796 c2+='</svg>';
26797
26798 // ── Chart 3: Language Code Delta ─────────────────────────────────────
26799 var c3='';
26800 if(langs.length){
26801 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26802 var C3W=550,c3LW=124,c3FW=52;
26803 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
26804 var L3rH=30,C3H=langs.length*L3rH+20;
26805 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26806 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26807 langs.forEach(function(l,i){
26808 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
26809 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
26810 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
26811 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26812 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':''))+'/>';
26813 if(bw>=48){
26814 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>';
26815 }else{
26816 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
26817 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>';
26818 }
26819 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>';
26820 });
26821 c3+='</svg>';
26822 }
26823
26824 // ── Chart 4: File Change Donut — centered pie with legend below
26825 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;});
26826 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26827 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26828 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">';
26829 var ang=-Math.PI/2;
26830 segs.forEach(function(s){
26831 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
26832 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
26833 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
26834 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
26835 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
26836 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)+'%')+'/>';
26837 ang+=sw;
26838 });
26839 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>';
26840 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
26841 segs.forEach(function(s,i){
26842 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
26843 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
26844 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>';
26845 });
26846 c4+='</svg>';
26847
26848 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
26849 var ttJs='var tt=document.getElementById("ox-tt");'+
26850 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
26851 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
26852 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
26853 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
26854 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
26855 'function oxHT(){tt.style.display="none";}';
26856
26857 // body max-width keeps charts from inflating beyond design dimensions on
26858 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
26859 // each chart's height blows up proportionally, breaking the one-page layout.
26860 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;}'+
26861 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
26862 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
26863 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
26864 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
26865 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
26866 'svg{display:block;}'+
26867 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
26868 '#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;}'+
26869 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
26870 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
26871 '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26872 '<div id="ox-tt"><\/div>'+
26873 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26874 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
26875 '<div class="two-col">'+
26876 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26877 '<div class="leg">'+
26878 '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26879 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26880 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
26881 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
26882 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
26883 '<\/div>'+
26884 '<div class="two-col">'+
26885 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
26886 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
26887 '<\/div>'+
26888 '<script>'+ttJs+'<\/script>'+
26889 '<\/body><\/html>';
26890 slocDownload(html, fname, 'text/html;charset=utf-8;');
26891 }
26892 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
26893 window.buildDeltaChartsHtml = function() {
26894 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26895 var sd=_sd;
26896 var projEl=document.querySelector('[data-folder]');
26897 var proj=projEl?projEl.getAttribute('data-folder'):'';
26898 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
26899 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
26900 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
26901 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
26902 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";}';
26903 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);}';
26904 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
26905 '<div id="ox-tt"><\/div>'+
26906 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
26907 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' → '+esc(sd.cts||'')+'<\/p>'+
26908 '<div class="two-col">'+
26909 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
26910 '<div class="leg"><span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
26911 '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
26912 '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
26913 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
26914 '<\/div>'+
26915 '<div class="two-col">'+
26916 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
26917 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
26918 '<\/div>'+
26919 '<script>'+ttJs+'<\/script>'+
26920 '<\/body><\/html>';
26921 };
26922 // ── Inline delta charts ────────────────────────────────────────────────────
26923 var _icTT=document.getElementById('ic-tt');
26924 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
26925 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';};
26926 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
26927 window.addEventListener('blur',function(){window.icHT();});
26928 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
26929 (function(){
26930 var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
26931 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
26932 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();}
26933 function px(n){return Math.round(n);}
26934 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
26935 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
26936 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);});}
26937 var dr=getDeltaExportRows(),sd=_sd,lm={};
26938 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;});
26939 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
26940 // Chart 1: Baseline vs Current grouped bars
26941 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'}];
26942 var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
26943 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;
26944 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26945 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"/>';}
26946 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
26947 c1mets.forEach(function(m,i){
26948 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
26949 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
26950 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>';
26951 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"/>';
26952 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>';
26953 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"/>';
26954 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>';
26955 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>';
26956 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>';
26957 });
26958 c1+='</svg>';
26959 // Chart 2: Delta by Metric
26960 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'}];
26961 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
26962 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;
26963 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26964 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26965 mets.forEach(function(m,i){
26966 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);
26967 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>';
26968 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
26969 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>';}
26970 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>';}
26971 });
26972 c2+='</svg>';
26973 // Chart 3: Language Code Delta
26974 var c3='';
26975 if(langs.length){
26976 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
26977 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;
26978 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
26979 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
26980 langs.forEach(function(l,i){
26981 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);
26982 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
26983 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"/>';
26984 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>';}
26985 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>';}
26986 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>';
26987 });
26988 c3+='</svg>';
26989 }
26990 // Chart 4: File Change Donut — centered pie with legend below
26991 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;});
26992 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
26993 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
26994 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;
26995 if(segs.length===1){
26996 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
26997 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
26998 } else {
26999 segs.forEach(function(s){
27000 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
27001 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);
27002 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);
27003 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"/>';
27004 ang+=sw;
27005 });
27006 }
27007 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>';
27008 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
27009 segs.forEach(function(s,i){
27010 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
27011 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;"/>';
27012 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>';
27013 });
27014 c4+='</svg>';
27015 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
27016 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
27017 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);}
27018 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
27019 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
27020
27021 // Compare Timeline chart (Baseline vs Current, 2 points)
27022 (function() {
27023 var activeCmpMetric='code';
27024 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
27025 function renderCmpTL(metric) {
27026 var svg=document.getElementById('cmp-tl-svg');if(!svg)return;
27027 var W=svg.getBoundingClientRect().width||800,H=280;
27028 svg.setAttribute('height',H);
27029 var pad={l:62,r:20,t:32,b:72};
27030 var dark=document.body.classList.contains('dark-theme');
27031 var cmpPts=[
27032 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
27033 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
27034 ];
27035 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
27036 var valid=pts.filter(function(v){return v!=null;});
27037 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;}
27038 var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
27039 if(minV===maxV){minV=Math.max(0,minV-1);maxV=maxV+1;}
27040 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
27041 var cx0=pad.l,cx1=pad.l+plotW;
27042 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27043 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
27044 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
27045 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
27046 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
27047 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();}
27048 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27049 var parts=[];
27050 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
27051 for(var gi=0;gi<5;gi++){
27052 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
27053 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
27054 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
27055 }
27056 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+'"/>');
27057 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"/>');
27058 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
27059 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
27060 dotPts.forEach(function(pt){
27061 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>');
27062 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"/>');
27063 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>');
27064 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>');
27065 });
27066 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
27067 svg.setAttribute('viewBox','0 0 '+W+' '+H);
27068 svg.innerHTML=parts.join('');
27069 // Hover: crosshair + tooltip (matches multi-scan timeline)
27070 var cmpTT=document.getElementById('ic-tt');
27071 svg.onmousemove=function(e){
27072 var rect=svg.getBoundingClientRect();
27073 var scaleX=W/rect.width;
27074 var mouseX=(e.clientX-rect.left)*scaleX;
27075 var nearest=-1,minDist=Infinity;
27076 var cxArr=[cx0,cx1];
27077 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;}}
27078 if(nearest<0)return;
27079 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
27080 var xhair=svg.querySelector('.cmp-xhair');
27081 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
27082 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"/>';
27083 if(!cmpTT)return;
27084 var clbl=cmpPts[nearest].label;
27085 var scanLbl=nearest===0?'Baseline':'Current';
27086 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>';
27087 var bx=rect.left+(nc/W*rect.width)+18;
27088 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
27089 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
27090 };
27091 svg.onmouseleave=function(){
27092 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
27093 if(cmpTT)cmpTT.style.display='none';
27094 };
27095 }
27096 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
27097 btn.addEventListener('click',function(){
27098 activeCmpMetric=this.dataset.cmpMetric;
27099 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
27100 this.classList.add('active');
27101 renderCmpTL(activeCmpMetric);
27102 });
27103 });
27104 var ttgl=document.getElementById('theme-toggle');
27105 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);},0);});
27106 if(typeof ResizeObserver!=='undefined'){
27107 var cmpSvg=document.getElementById('cmp-tl-svg');
27108 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
27109 }
27110 renderCmpTL(activeCmpMetric);
27111 })();
27112
27113 // HTML legend hover -> highlight matching SVG bars within the SAME card only
27114 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
27115 var metric=leg.getAttribute('data-highlight');
27116 var parentCard=leg.closest('.ic-card');
27117 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
27118 if(!chartEl)return;
27119 leg.addEventListener('mouseenter',function(){
27120 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
27121 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';}
27122 else{x.style.opacity='0.28';}
27123 });
27124 });
27125 leg.addEventListener('mouseleave',function(){
27126 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
27127 });
27128 });
27129 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
27130 })();
27131 </script>
27132 <script nonce="{{ csp_nonce }}">
27133 (function(){
27134 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'}];
27135 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);});}
27136 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27137 function init(){
27138 var btn=document.getElementById('settings-btn');if(!btn)return;
27139 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27140 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>';
27141 document.body.appendChild(m);
27142 var g=document.getElementById('scheme-grid');
27143 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);});
27144 var cl=document.getElementById('settings-close');
27145 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);
27146 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');});
27147 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27148 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27149 }
27150 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27151 }());
27152 </script>
27153 <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]';
27154 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;}
27155 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>
27156</body>
27157</html>
27158"##,
27159 ext = "html"
27160)]
27161#[allow(clippy::struct_excessive_bools)]
27163struct CompareTemplate {
27164 version: &'static str,
27165 project_label: String,
27166 baseline_git_commit: String,
27167 current_git_commit: String,
27168 baseline_run_id: String,
27169 current_run_id: String,
27170 baseline_run_id_short: String,
27171 current_run_id_short: String,
27172 baseline_timestamp: String,
27173 baseline_timestamp_utc_ms: i64,
27174 current_timestamp: String,
27175 current_timestamp_utc_ms: i64,
27176 project_path: String,
27177 baseline_code: u64,
27178 current_code: u64,
27179 code_lines_delta_str: String,
27180 code_lines_delta_class: String,
27181 baseline_files: u64,
27182 current_files: u64,
27183 files_analyzed_delta_str: String,
27184 files_analyzed_delta_class: String,
27185 baseline_comments: u64,
27186 current_comments: u64,
27187 comment_lines_delta_str: String,
27188 comment_lines_delta_class: String,
27189 baseline_code_fmt: String,
27190 current_code_fmt: String,
27191 baseline_files_fmt: String,
27192 current_files_fmt: String,
27193 baseline_comments_fmt: String,
27194 current_comments_fmt: String,
27195 code_lines_pct_str: String,
27196 files_analyzed_pct_str: String,
27197 comment_lines_pct_str: String,
27198 code_lines_added: i64,
27199 code_lines_removed: i64,
27200 new_scope: bool,
27202 churn_rate_str: String,
27203 churn_rate_class: String,
27204 scope_flag: bool,
27205 files_added: usize,
27206 files_removed: usize,
27207 files_modified: usize,
27208 files_unchanged: usize,
27209 file_rows: Vec<CompareFileDeltaRow>,
27210 baseline_git_author: Option<String>,
27211 current_git_author: Option<String>,
27212 baseline_git_branch: String,
27213 current_git_branch: String,
27214 baseline_git_tags: Option<String>,
27215 current_git_tags: Option<String>,
27216 baseline_git_commit_date: Option<String>,
27217 current_git_commit_date: Option<String>,
27218 project_name: String,
27219 submodule_options: Vec<String>,
27221 has_any_submodule_data: bool,
27223 active_submodule: Option<String>,
27225 super_scope_active: bool,
27227 csp_nonce: String,
27228 coverage_delta_card: String,
27230 baseline_test_count: u64,
27231 current_test_count: u64,
27232 baseline_coverage_pct: Option<f64>,
27233 current_coverage_pct: Option<f64>,
27234}
27235
27236#[derive(Template)]
27239#[template(
27240 source = r##"
27241<!doctype html>
27242<html lang="en">
27243<head>
27244 <meta charset="utf-8">
27245 <meta name="viewport" content="width=device-width, initial-scale=1">
27246 <title>OxideSLOC | Sign In</title>
27247 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27248 <style nonce="{{ csp_nonce }}">
27249 :root {
27250 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
27251 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
27252 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
27253 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
27254 }
27255 *{box-sizing:border-box;}
27256 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);}
27257 .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);}
27258 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
27259 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
27260 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
27261 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27262 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27263 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27264 .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;}
27265 @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));}}
27266 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
27267 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
27268 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27269 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
27270 .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;}
27271 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
27272 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;}
27273 input[type=password]:focus{border-color:var(--oxide);}
27274 .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;}
27275 .btn:hover{opacity:.88;}
27276 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
27277 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
27278 </style>
27279</head>
27280<body>
27281 <div class="background-watermarks" aria-hidden="true">
27282 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27287 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27288 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27289 </div>
27290 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27291<nav class="top-nav">
27292 <a class="brand" href="/">
27293 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
27294 <span class="brand-title">OxideSLOC</span>
27295 </a>
27296</nav>
27297<main class="page">
27298 <div class="card">
27299 <h1>Sign In</h1>
27300 <p class="subtitle">Enter the API key printed when the server started.</p>
27301 {% if has_error %}
27302 <div class="error">Incorrect API key — please try again.</div>
27303 {% endif %}
27304 <form method="POST" action="/auth/login">
27305 <input type="hidden" name="next" value="{{ next_url|e }}">
27306 <label for="key">API Key</label>
27307 <input id="key" type="password" name="key" autocomplete="current-password"
27308 placeholder="Paste your API key here" autofocus>
27309 <button type="submit" class="btn">Sign In</button>
27310 </form>
27311 <p class="hint">
27312 The API key was printed in the terminal when the server started.<br>
27313 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
27314 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
27315 </p>
27316 </div>
27317</main>
27318<script nonce="{{ csp_nonce }}">
27319(function() {
27320 (function randomizeWatermarks() {
27321 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27322 if (!wms.length) return;
27323 var placed = [];
27324 function tooClose(top, left) {
27325 for (var i = 0; i < placed.length; i++) {
27326 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
27327 if (dt < 16 && dl < 12) return true;
27328 }
27329 return false;
27330 }
27331 function pick(leftBand) {
27332 for (var attempt = 0; attempt < 50; attempt++) {
27333 var top = Math.random() * 88 + 2;
27334 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27335 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
27336 }
27337 var top = Math.random() * 88 + 2;
27338 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
27339 placed.push([top, left]); return [top, left];
27340 }
27341 var half = Math.floor(wms.length / 2);
27342 wms.forEach(function (img, i) {
27343 var pos = pick(i < half);
27344 var size = Math.floor(Math.random() * 100 + 120);
27345 var rot = (Math.random() * 360).toFixed(1);
27346 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
27347 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;
27348 });
27349 })();
27350 (function spawnCodeParticles() {
27351 var container = document.getElementById('code-particles');
27352 if (!container) return;
27353 var snippets = [
27354 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
27355 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
27356 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
27357 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
27358 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
27359 ];
27360 var count = 38;
27361 for (var i = 0; i < count; i++) {
27362 (function(idx) {
27363 var el = document.createElement('span');
27364 el.className = 'code-particle';
27365 el.textContent = snippets[idx % snippets.length];
27366 var left = Math.random() * 94 + 2;
27367 var top = Math.random() * 88 + 6;
27368 var dur = (Math.random() * 10 + 9).toFixed(1);
27369 var delay = (Math.random() * 18).toFixed(1);
27370 var rot = (Math.random() * 26 - 13).toFixed(1);
27371 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27372 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
27373 container.appendChild(el);
27374 })(i);
27375 }
27376 })();
27377})();
27378</script>
27379</body>
27380</html>
27381"##,
27382 ext = "html"
27383)]
27384pub(crate) struct LoginTemplate {
27385 pub(crate) csp_nonce: String,
27386 pub(crate) has_error: bool,
27387 pub(crate) next_url: String,
27388 pub(crate) lockout_threshold: u32,
27389}
27390
27391#[derive(Template)]
27394#[template(
27395 source = r##"
27396<!doctype html>
27397<html lang="en">
27398<head>
27399 <meta charset="utf-8">
27400 <meta name="viewport" content="width=device-width, initial-scale=1">
27401 <title>OxideSLOC — REST API Reference</title>
27402 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27403 <style nonce="{{ csp_nonce }}">
27404 :root {
27405 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27406 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27407 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27408 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27409 --success:#16a34a;
27410 }
27411 body.dark-theme {
27412 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
27413 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
27414 }
27415 *{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;}
27416 .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);}
27417 .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;}
27418 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
27419 .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));}
27420 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
27421 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
27422 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
27423 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
27424 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27425 @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; } }
27426 .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;}
27427 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
27428 .nav-pill.active{background:rgba(255,255,255,0.22);}
27429 .nav-dropdown{position:relative;display:inline-flex;}
27430 .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;}
27431 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
27432 .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;}
27433 .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;}
27434 .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);}
27435 .nav-dropdown-menu a:last-child{border-bottom:none;}
27436 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
27437 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
27438 .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;}
27439 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27440 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27441 .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;}
27442 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27443 .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);}
27444 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
27445 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
27446 .settings-modal-body{padding:14px 16px 16px;}
27447 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27448 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27449 .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;}
27450 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27451 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27452 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27453 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27454 .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;}
27455 .tz-select:focus{border-color:var(--oxide);}
27456 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
27457 .page-header{margin-bottom:28px;}
27458 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
27459 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
27460 .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;}
27461 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
27462 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
27463 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
27464 .callout strong{font-weight:800;}
27465 .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;}
27466 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
27467 .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;}
27468 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
27469 .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;}
27470 body.dark-theme .base-url-value{color:var(--accent);}
27471 .section{margin-bottom:36px;}
27472 .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);}
27473 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
27474 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
27475 .ep-header:hover{background:var(--surface-2);}
27476 .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;}
27477 .method.get{background:#dcfce7;color:#166534;}
27478 .method.post{background:#dbeafe;color:#1e40af;}
27479 .method.delete{background:#fee2e2;color:#991b1b;}
27480 body.dark-theme .method.get{background:#14532d;color:#86efac;}
27481 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
27482 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
27483 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
27484 .ep-path .param{color:var(--oxide-2);}
27485 body.dark-theme .ep-path .param{color:var(--oxide);}
27486 .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;}
27487 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
27488 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
27489 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
27490 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
27491 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
27492 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
27493 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
27494 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
27495 .ep-card.open .chevron{transform:rotate(180deg);}
27496 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
27497 .ep-card.open .ep-body{display:block;}
27498 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
27499 .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;}
27500 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
27501 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
27502 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27503 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
27504 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);}
27505 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
27506 table.params tr:last-child td{border-bottom:none;}
27507 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
27508 .pt-type{color:var(--muted-2);font-size:12px;}
27509 .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;}
27510 .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;}
27511 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
27512 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
27513 details.schema{margin-bottom:14px;}
27514 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;}
27515 details.schema summary:hover{color:var(--text);}
27516 .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;}
27517 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
27518 .curl-wrap{position:relative;}
27519 .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;}
27520 .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;}
27521 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
27522 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
27523 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
27524 .webhook-note a{color:var(--accent-2);text-decoration:none;}
27525 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27526 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27527 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27528 .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;}
27529 @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));}}
27530 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27531 .site-footer a{color:var(--muted);}
27532 </style>
27533</head>
27534<body>
27535 <div class="background-watermarks" aria-hidden="true">
27536 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27537 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27538 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27539 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27540 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27541 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27542 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27543 </div>
27544 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27545 <div class="top-nav">
27546 <div class="top-nav-inner">
27547 <a class="brand" href="/">
27548 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27549 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
27550 </a>
27551 <div class="nav-right">
27552 <a class="nav-pill" href="/">Home</a>
27553 <div class="nav-dropdown">
27554 <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>
27555 <div class="nav-dropdown-menu">
27556 <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>
27557 </div>
27558 </div>
27559 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27560 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27561 <div class="nav-dropdown">
27562 <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>
27563 <div class="nav-dropdown-menu">
27564 <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>
27565 </div>
27566 </div>
27567 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27568 <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>
27569 </button>
27570 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27571 <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>
27572 <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>
27573 </button>
27574 </div>
27575 </div>
27576 </div>
27577
27578 <div class="page">
27579 <div class="page-header">
27580 <h1 class="page-title">REST API Reference</h1>
27581 <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>
27582 </div>
27583
27584 {% if has_api_key %}
27585 <div class="callout key-set">
27586 <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>
27587 <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>
27588 </div>
27589 {% else %}
27590 <div class="callout no-key">
27591 <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>
27592 <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>
27593 </div>
27594 {% endif %}
27595
27596 <div class="base-url-bar">
27597 <span class="base-url-label">Base URL</span>
27598 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
27599 </div>
27600
27601 <!-- Health -->
27602 <div class="section">
27603 <h2 class="section-title">Health & Status</h2>
27604 <div class="ep-card">
27605 <div class="ep-header">
27606 <span class="method get">GET</span>
27607 <span class="ep-path">/healthz</span>
27608 <span class="auth-badge public">Public</span>
27609 <span class="ep-desc">Server liveness check</span>
27610 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27611 </div>
27612 <div class="ep-body">
27613 <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>
27614 <p class="params-heading">Response</p>
27615 <div class="schema-block">200 OK
27616Content-Type: text/plain
27617
27618ok</div>
27619 <p class="curl-heading">Example</p>
27620 <div class="curl-wrap">
27621 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
27622 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
27623 </div>
27624 </div>
27625 </div>
27626 </div>
27627
27628 <!-- Badges -->
27629 <div class="section">
27630 <h2 class="section-title">Badges</h2>
27631 <div class="ep-card">
27632 <div class="ep-header">
27633 <span class="method get">GET</span>
27634 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
27635 <span class="auth-badge public">Public</span>
27636 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
27637 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27638 </div>
27639 <div class="ep-body">
27640 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
27641 <p class="params-heading">Path Parameters</p>
27642 <table class="params">
27643 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27644 <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>
27645 </table>
27646 <p class="curl-heading">Example</p>
27647 <div class="curl-wrap">
27648 <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>
27649 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
27650 </div>
27651 </div>
27652 </div>
27653 </div>
27654
27655 <!-- Metrics -->
27656 <div class="section">
27657 <h2 class="section-title">Metrics</h2>
27658
27659 <div class="ep-card">
27660 <div class="ep-header">
27661 <span class="method get">GET</span>
27662 <span class="ep-path">/api/metrics/latest</span>
27663 <span class="auth-badge protected">Protected</span>
27664 <span class="ep-desc">Latest scan metrics (JSON)</span>
27665 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27666 </div>
27667 <div class="ep-body">
27668 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
27669 <details class="schema"><summary>Response schema</summary>
27670<div class="schema-block">{
27671 "run_id": string, // UUID
27672 "timestamp": string, // ISO-8601 UTC
27673 "project": string, // scanned root path
27674 "summary": {
27675 "files_analyzed": number,
27676 "files_skipped": number,
27677 "code_lines": number,
27678 "comment_lines": number,
27679 "blank_lines": number,
27680 "total_physical_lines": number,
27681 "functions": number,
27682 "classes": number,
27683 "variables": number,
27684 "imports": number
27685 },
27686 "languages": [
27687 { "name": string, "files": number, "code_lines": number,
27688 "comment_lines": number, "blank_lines": number,
27689 "functions": number, "classes": number,
27690 "variables": number, "imports": number }
27691 ]
27692}</div></details>
27693 <p class="curl-heading">Example</p>
27694 <div class="curl-wrap">
27695 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27696 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
27697 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
27698 </div>
27699 </div>
27700 </div>
27701
27702 <div class="ep-card">
27703 <div class="ep-header">
27704 <span class="method get">GET</span>
27705 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
27706 <span class="auth-badge protected">Protected</span>
27707 <span class="ep-desc">Metrics for a specific run</span>
27708 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27709 </div>
27710 <div class="ep-body">
27711 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
27712 <p class="params-heading">Path Parameters</p>
27713 <table class="params">
27714 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27715 <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>
27716 </table>
27717 <p class="curl-heading">Example</p>
27718 <div class="curl-wrap">
27719 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27720 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
27721 <button class="curl-copy-btn" data-target="c-metrics-run">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/metrics/history</span>
27730 <span class="auth-badge protected">Protected</span>
27731 <span class="ep-desc">Paginated scan history</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 an array of scan history entries, newest-first. Optionally filtered by root path.</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">root</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by scanned root path</td></tr>
27740 <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>
27741 </table>
27742 <details class="schema"><summary>Response schema</summary>
27743<div class="schema-block">[{
27744 "run_id": string,
27745 "timestamp": string, // ISO-8601 UTC
27746 "commit": string | null,
27747 "branch": string | null,
27748 "tags": string[],
27749 "code_lines": number,
27750 "comment_lines": number,
27751 "blank_lines": number,
27752 "physical_lines": number,
27753 "files_analyzed": number,
27754 "project_label": string,
27755 "html_url": string | null
27756}]</div></details>
27757 <p class="curl-heading">Example</p>
27758 <div class="curl-wrap">
27759 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27760 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
27761 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
27762 </div>
27763 </div>
27764 </div>
27765
27766 <div class="ep-card">
27767 <div class="ep-header">
27768 <span class="method get">GET</span>
27769 <span class="ep-path">/api/project-history</span>
27770 <span class="auth-badge protected">Protected</span>
27771 <span class="ep-desc">Project-level scan summary</span>
27772 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27773 </div>
27774 <div class="ep-body">
27775 <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>
27776 <p class="params-heading">Query Parameters</p>
27777 <table class="params">
27778 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27779 <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>
27780 </table>
27781 <details class="schema"><summary>Response schema</summary>
27782<div class="schema-block">{
27783 "scan_count": number,
27784 "last_scan_id": string | null,
27785 "last_scan_timestamp": string | null, // ISO-8601
27786 "last_scan_code_lines": number | null,
27787 "last_git_branch": string | null,
27788 "last_git_commit": string | null
27789}</div></details>
27790 <p class="curl-heading">Example</p>
27791 <div class="curl-wrap">
27792 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27793 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
27794 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
27795 </div>
27796 </div>
27797 </div>
27798
27799 <div class="ep-card">
27800 <div class="ep-header">
27801 <span class="method get">GET</span>
27802 <span class="ep-path">/api/metrics/submodules</span>
27803 <span class="auth-badge protected">Protected</span>
27804 <span class="ep-desc">List known git submodules across scans</span>
27805 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27806 </div>
27807 <div class="ep-body">
27808 <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>
27809 <p class="params-heading">Query Parameters</p>
27810 <table class="params">
27811 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27812 <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>
27813 </table>
27814 <details class="schema"><summary>Response schema</summary>
27815<div class="schema-block">[{
27816 "name": string, // submodule name
27817 "relative_path": string // path relative to the project root
27818}]</div></details>
27819 <p class="curl-heading">Example</p>
27820 <div class="curl-wrap">
27821 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27822 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
27823 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
27824 </div>
27825 </div>
27826 </div>
27827 </div>
27828
27829 <!-- Async Run Status -->
27830 <div class="section">
27831 <h2 class="section-title">Async Run Status</h2>
27832
27833 <div class="ep-card">
27834 <div class="ep-header">
27835 <span class="method get">GET</span>
27836 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
27837 <span class="auth-badge protected">Protected</span>
27838 <span class="ep-desc">Poll scan completion</span>
27839 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27840 </div>
27841 <div class="ep-body">
27842 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
27843 <details class="schema"><summary>Response schema</summary>
27844<div class="schema-block">// Running
27845{ "state": "running", "elapsed_secs": number }
27846
27847// Complete
27848{ "state": "complete", "run_id": string }
27849
27850// Failed
27851{ "state": "failed", "message": string }</div></details>
27852 <p class="curl-heading">Example</p>
27853 <div class="curl-wrap">
27854 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27855 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
27856 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
27857 </div>
27858 </div>
27859 </div>
27860
27861 <div class="ep-card">
27862 <div class="ep-header">
27863 <span class="method get">GET</span>
27864 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
27865 <span class="auth-badge protected">Protected</span>
27866 <span class="ep-desc">Poll PDF generation readiness</span>
27867 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27868 </div>
27869 <div class="ep-body">
27870 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
27871 <details class="schema"><summary>Response schema</summary>
27872<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
27873 <p class="curl-heading">Example</p>
27874 <div class="curl-wrap">
27875 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27876 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
27877 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
27878 </div>
27879 </div>
27880 </div>
27881
27882 <div class="ep-card">
27883 <div class="ep-header">
27884 <span class="method post">POST</span>
27885 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
27886 <span class="auth-badge protected">Protected</span>
27887 <span class="ep-desc">Cancel a running scan</span>
27888 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27889 </div>
27890 <div class="ep-body">
27891 <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>
27892 <p class="curl-heading">Example</p>
27893 <div class="curl-wrap">
27894 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
27895 -H "Authorization: Bearer $SLOC_API_KEY" \
27896 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
27897 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
27898 </div>
27899 </div>
27900 </div>
27901 </div>
27902
27903 <!-- Run Management -->
27904 <div class="section">
27905 <h2 class="section-title">Run Management</h2>
27906
27907 <div class="ep-card">
27908 <div class="ep-header">
27909 <span class="method get">GET</span>
27910 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
27911 <span class="auth-badge protected">Protected</span>
27912 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
27913 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27914 </div>
27915 <div class="ep-body">
27916 <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>
27917 <p class="params-heading">Path Parameters</p>
27918 <table class="params">
27919 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27920 <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>
27921 </table>
27922 <details class="schema"><summary>Response</summary>
27923<div class="schema-block">200 OK — Content-Type: application/zip
27924Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
27925
27926404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
27927 <p class="curl-heading">Example</p>
27928 <div class="curl-wrap">
27929 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
27930 -o run.zip \
27931 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
27932 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
27933 </div>
27934 </div>
27935 </div>
27936
27937 <div class="ep-card">
27938 <div class="ep-header">
27939 <span class="method delete">DELETE</span>
27940 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
27941 <span class="auth-badge protected">Protected</span>
27942 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
27943 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27944 </div>
27945 <div class="ep-body">
27946 <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>
27947 <p class="params-heading">Path Parameters</p>
27948 <table class="params">
27949 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
27950 <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>
27951 </table>
27952 <details class="schema"><summary>Response</summary>
27953<div class="schema-block">204 No Content — run successfully deleted
27954
27955500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
27956 <p class="curl-heading">Example</p>
27957 <div class="curl-wrap">
27958 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
27959 -H "Authorization: Bearer $SLOC_API_KEY" \
27960 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
27961 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
27962 </div>
27963 </div>
27964 </div>
27965
27966 <div class="ep-card">
27967 <div class="ep-header">
27968 <span class="method post">POST</span>
27969 <span class="ep-path">/api/runs/cleanup</span>
27970 <span class="auth-badge protected">Protected</span>
27971 <span class="ep-desc">Bulk delete runs older than N days</span>
27972 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
27973 </div>
27974 <div class="ep-body">
27975 <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>
27976 <p class="params-heading">Request Body (application/json)</p>
27977 <table class="params">
27978 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
27979 <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>
27980 </table>
27981 <details class="schema"><summary>Response schema</summary>
27982<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
27983 <p class="curl-heading">Example — delete runs older than 60 days</p>
27984 <div class="curl-wrap">
27985 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
27986 -H "Authorization: Bearer $SLOC_API_KEY" \
27987 -H "Content-Type: application/json" \
27988 -d '{"older_than_days":60}' \
27989 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
27990 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
27991 </div>
27992 </div>
27993 </div>
27994 </div>
27995
27996 <!-- Retention Policy -->
27997 <div class="section">
27998 <h2 class="section-title">Retention Policy</h2>
27999
28000 <div class="ep-card">
28001 <div class="ep-header">
28002 <span class="method get">GET</span>
28003 <span class="ep-path">/api/cleanup-policy</span>
28004 <span class="auth-badge protected">Protected</span>
28005 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
28006 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28007 </div>
28008 <div class="ep-body">
28009 <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>
28010 <details class="schema"><summary>Response schema</summary>
28011<div class="schema-block">{
28012 "policy": {
28013 "enabled": boolean,
28014 "max_age_days": number | null, // delete runs older than N days
28015 "max_run_count": number | null, // keep only the N most recent runs
28016 "interval_hours": number // hours between background passes
28017 } | null,
28018 "last_run_at": string | null, // ISO-8601 UTC timestamp
28019 "last_run_deleted": number | null // runs deleted in last pass
28020}</div></details>
28021 <p class="curl-heading">Example</p>
28022 <div class="curl-wrap">
28023 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28024 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28025 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
28026 </div>
28027 </div>
28028 </div>
28029
28030 <div class="ep-card">
28031 <div class="ep-header">
28032 <span class="method post">POST</span>
28033 <span class="ep-path">/api/cleanup-policy</span>
28034 <span class="auth-badge protected">Protected</span>
28035 <span class="ep-desc">Save or update the retention policy</span>
28036 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28037 </div>
28038 <div class="ep-body">
28039 <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>
28040 <p class="params-heading">Request Body (application/json)</p>
28041 <table class="params">
28042 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28043 <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>
28044 <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>
28045 <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>
28046 <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>
28047 </table>
28048 <details class="schema"><summary>Response</summary>
28049<div class="schema-block">204 No Content — policy saved and task (re)started
28050
28051500 Internal Server Error — { "error": string }</div></details>
28052 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
28053 <div class="curl-wrap">
28054 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
28055 -H "Authorization: Bearer $SLOC_API_KEY" \
28056 -H "Content-Type: application/json" \
28057 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
28058 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28059 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
28060 </div>
28061 </div>
28062 </div>
28063
28064 <div class="ep-card">
28065 <div class="ep-header">
28066 <span class="method post">POST</span>
28067 <span class="ep-path">/api/cleanup-policy/run-now</span>
28068 <span class="auth-badge protected">Protected</span>
28069 <span class="ep-desc">Trigger an immediate cleanup pass</span>
28070 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28071 </div>
28072 <div class="ep-body">
28073 <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>
28074 <details class="schema"><summary>Response schema</summary>
28075<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
28076 <p class="curl-heading">Example</p>
28077 <div class="curl-wrap">
28078 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
28079 -H "Authorization: Bearer $SLOC_API_KEY" \
28080 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
28081 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
28082 </div>
28083 </div>
28084 </div>
28085
28086 <div class="ep-card">
28087 <div class="ep-header">
28088 <span class="method delete">DELETE</span>
28089 <span class="ep-path">/api/cleanup-policy</span>
28090 <span class="auth-badge protected">Protected</span>
28091 <span class="ep-desc">Remove the retention policy and stop the background task</span>
28092 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28093 </div>
28094 <div class="ep-body">
28095 <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>
28096 <details class="schema"><summary>Response</summary>
28097<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
28098 <p class="curl-heading">Example</p>
28099 <div class="curl-wrap">
28100 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
28101 -H "Authorization: Bearer $SLOC_API_KEY" \
28102 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
28103 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
28104 </div>
28105 </div>
28106 </div>
28107 </div>
28108
28109 <!-- Scan Profiles -->
28110 <div class="section">
28111 <h2 class="section-title">Scan Profiles</h2>
28112
28113 <div class="ep-card">
28114 <div class="ep-header">
28115 <span class="method get">GET</span>
28116 <span class="ep-path">/api/scan-profiles</span>
28117 <span class="auth-badge protected">Protected</span>
28118 <span class="ep-desc">List saved scan profiles</span>
28119 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28120 </div>
28121 <div class="ep-body">
28122 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
28123 <details class="schema"><summary>Response schema</summary>
28124<div class="schema-block">{
28125 "profiles": [{
28126 "id": string, // UUID
28127 "name": string,
28128 "created_at": string, // ISO-8601
28129 "params": object
28130 }]
28131}</div></details>
28132 <p class="curl-heading">Example</p>
28133 <div class="curl-wrap">
28134 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28135 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28136 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
28137 </div>
28138 </div>
28139 </div>
28140
28141 <div class="ep-card">
28142 <div class="ep-header">
28143 <span class="method post">POST</span>
28144 <span class="ep-path">/api/scan-profiles</span>
28145 <span class="auth-badge protected">Protected</span>
28146 <span class="ep-desc">Save a scan profile</span>
28147 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28148 </div>
28149 <div class="ep-body">
28150 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
28151 <p class="params-heading">Request Body (application/json)</p>
28152 <table class="params">
28153 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28154 <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>
28155 <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>
28156 </table>
28157 <details class="schema"><summary>Response schema</summary>
28158<div class="schema-block">{ "ok": true }</div></details>
28159 <p class="curl-heading">Example</p>
28160 <div class="curl-wrap">
28161 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
28162 -H "Authorization: Bearer $SLOC_API_KEY" \
28163 -H "Content-Type: application/json" \
28164 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
28165 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
28166 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
28167 </div>
28168 </div>
28169 </div>
28170
28171 <div class="ep-card">
28172 <div class="ep-header">
28173 <span class="method delete">DELETE</span>
28174 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
28175 <span class="auth-badge protected">Protected</span>
28176 <span class="ep-desc">Delete a scan profile</span>
28177 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28178 </div>
28179 <div class="ep-body">
28180 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
28181 <p class="params-heading">Path Parameters</p>
28182 <table class="params">
28183 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28184 <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>
28185 </table>
28186 <details class="schema"><summary>Response schema</summary>
28187<div class="schema-block">{ "ok": true }</div></details>
28188 <p class="curl-heading">Example</p>
28189 <div class="curl-wrap">
28190 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
28191 -H "Authorization: Bearer $SLOC_API_KEY" \
28192 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
28193 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
28194 </div>
28195 </div>
28196 </div>
28197 </div>
28198
28199 <!-- Scheduled Scans -->
28200 <div class="section">
28201 <h2 class="section-title">Scheduled Scans</h2>
28202
28203 <div class="ep-card">
28204 <div class="ep-header">
28205 <span class="method get">GET</span>
28206 <span class="ep-path">/api/schedules</span>
28207 <span class="auth-badge protected">Protected</span>
28208 <span class="ep-desc">List configured schedules</span>
28209 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28210 </div>
28211 <div class="ep-body">
28212 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
28213 <p class="curl-heading">Example</p>
28214 <div class="curl-wrap">
28215 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28216 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28217 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
28218 </div>
28219 </div>
28220 </div>
28221
28222 <div class="ep-card">
28223 <div class="ep-header">
28224 <span class="method post">POST</span>
28225 <span class="ep-path">/api/schedules</span>
28226 <span class="auth-badge protected">Protected</span>
28227 <span class="ep-desc">Create a schedule</span>
28228 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28229 </div>
28230 <div class="ep-body">
28231 <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>
28232 <p class="curl-heading">Example</p>
28233 <div class="curl-wrap">
28234 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
28235 -H "Authorization: Bearer $SLOC_API_KEY" \
28236 -H "Content-Type: application/json" \
28237 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
28238 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28239 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
28240 </div>
28241 </div>
28242 </div>
28243
28244 <div class="ep-card">
28245 <div class="ep-header">
28246 <span class="method delete">DELETE</span>
28247 <span class="ep-path">/api/schedules</span>
28248 <span class="auth-badge protected">Protected</span>
28249 <span class="ep-desc">Delete a schedule</span>
28250 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28251 </div>
28252 <div class="ep-body">
28253 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
28254 <p class="curl-heading">Example</p>
28255 <div class="curl-wrap">
28256 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
28257 -H "Authorization: Bearer $SLOC_API_KEY" \
28258 -H "Content-Type: application/json" \
28259 -d '{"id":"<schedule_id>"}' \
28260 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
28261 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
28262 </div>
28263 </div>
28264 </div>
28265 </div>
28266
28267 <!-- Git Browser -->
28268 <div class="section">
28269 <h2 class="section-title">Git Browser</h2>
28270
28271 <div class="ep-card">
28272 <div class="ep-header">
28273 <span class="method get">GET</span>
28274 <span class="ep-path">/api/git/refs</span>
28275 <span class="auth-badge protected">Protected</span>
28276 <span class="ep-desc">List git refs for a repository</span>
28277 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28278 </div>
28279 <div class="ep-body">
28280 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
28281 <p class="params-heading">Query Parameters</p>
28282 <table class="params">
28283 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28284 <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>
28285 </table>
28286 <p class="curl-heading">Example</p>
28287 <div class="curl-wrap">
28288 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28289 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
28290 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
28291 </div>
28292 </div>
28293 </div>
28294
28295 <div class="ep-card">
28296 <div class="ep-header">
28297 <span class="method get">GET</span>
28298 <span class="ep-path">/api/git/scan-ref</span>
28299 <span class="auth-badge protected">Protected</span>
28300 <span class="ep-desc">SLOC-scan a specific git ref</span>
28301 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28302 </div>
28303 <div class="ep-body">
28304 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
28305 <p class="params-heading">Query Parameters</p>
28306 <table class="params">
28307 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28308 <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>
28309 <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>
28310 </table>
28311 <p class="curl-heading">Example</p>
28312 <div class="curl-wrap">
28313 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28314 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
28315 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
28316 </div>
28317 </div>
28318 </div>
28319
28320 <div class="ep-card">
28321 <div class="ep-header">
28322 <span class="method get">GET</span>
28323 <span class="ep-path">/api/git/compare-refs</span>
28324 <span class="auth-badge protected">Protected</span>
28325 <span class="ep-desc">Compare SLOC across two git refs</span>
28326 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28327 </div>
28328 <div class="ep-body">
28329 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
28330 <p class="params-heading">Query Parameters</p>
28331 <table class="params">
28332 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28333 <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>
28334 <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>
28335 <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>
28336 </table>
28337 <p class="curl-heading">Example</p>
28338 <div class="curl-wrap">
28339 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28340 "<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>
28341 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
28342 </div>
28343 </div>
28344 </div>
28345 </div>
28346
28347 <!-- Webhooks -->
28348 <div class="section">
28349 <h2 class="section-title">Webhooks</h2>
28350 <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>
28351
28352 <div class="ep-card">
28353 <div class="ep-header">
28354 <span class="method post">POST</span>
28355 <span class="ep-path">/webhooks/github</span>
28356 <span class="auth-badge hmac">HMAC</span>
28357 <span class="ep-desc">GitHub 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 GitHub <code>push</code> events and triggers an SLOC scan. Authenticated via <code>X-Hub-Signature-256</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-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
28366 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
28367 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28368 </table>
28369 </div>
28370 </div>
28371
28372 <div class="ep-card">
28373 <div class="ep-header">
28374 <span class="method post">POST</span>
28375 <span class="ep-path">/webhooks/gitlab</span>
28376 <span class="auth-badge hmac">HMAC</span>
28377 <span class="ep-desc">GitLab push event receiver</span>
28378 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28379 </div>
28380 <div class="ep-body">
28381 <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>
28382 <p class="params-heading">Required Headers</p>
28383 <table class="params">
28384 <tr><th>Header</th><th>Value</th></tr>
28385 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
28386 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
28387 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28388 </table>
28389 </div>
28390 </div>
28391
28392 <div class="ep-card">
28393 <div class="ep-header">
28394 <span class="method post">POST</span>
28395 <span class="ep-path">/webhooks/bitbucket</span>
28396 <span class="auth-badge hmac">HMAC</span>
28397 <span class="ep-desc">Bitbucket push event receiver</span>
28398 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28399 </div>
28400 <div class="ep-body">
28401 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
28402 <p class="params-heading">Required Headers</p>
28403 <table class="params">
28404 <tr><th>Header</th><th>Value</th></tr>
28405 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
28406 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
28407 </table>
28408 </div>
28409 </div>
28410 </div>
28411
28412 <!-- Config -->
28413 <div class="section">
28414 <h2 class="section-title">Config Import / Export</h2>
28415
28416 <div class="ep-card">
28417 <div class="ep-header">
28418 <span class="method get">GET</span>
28419 <span class="ep-path">/export-config</span>
28420 <span class="auth-badge protected">Protected</span>
28421 <span class="ep-desc">Export server configuration as JSON</span>
28422 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28423 </div>
28424 <div class="ep-body">
28425 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
28426 <p class="curl-heading">Example</p>
28427 <div class="curl-wrap">
28428 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28429 -o config.json \
28430 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
28431 <button class="curl-copy-btn" data-target="c-export">Copy</button>
28432 </div>
28433 </div>
28434 </div>
28435
28436 <div class="ep-card">
28437 <div class="ep-header">
28438 <span class="method post">POST</span>
28439 <span class="ep-path">/import-config</span>
28440 <span class="auth-badge protected">Protected</span>
28441 <span class="ep-desc">Import server configuration</span>
28442 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28443 </div>
28444 <div class="ep-body">
28445 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
28446 <p class="curl-heading">Example</p>
28447 <div class="curl-wrap">
28448 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
28449 -H "Authorization: Bearer $SLOC_API_KEY" \
28450 -H "Content-Type: application/json" \
28451 -d @config.json \
28452 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
28453 <button class="curl-copy-btn" data-target="c-import">Copy</button>
28454 </div>
28455 </div>
28456 </div>
28457 </div>
28458
28459 <!-- CI Ingest -->
28460 <div class="section">
28461 <h2 class="section-title">CI Ingest</h2>
28462
28463 <div class="ep-card">
28464 <div class="ep-header">
28465 <span class="method post">POST</span>
28466 <span class="ep-path">/api/ingest</span>
28467 <span class="auth-badge protected">Protected</span>
28468 <span class="ep-desc">Push a pre-computed scan result from CI</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">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>
28473 <p class="params-heading">Query 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">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>
28477 </table>
28478 <p class="params-heading">Request Body (application/json)</p>
28479 <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>
28480 <details class="schema"><summary>Response schema</summary>
28481<div class="schema-block">// 201 Created
28482{
28483 "run_id": string, // UUID of the ingested run
28484 "view_url": string // relative URL to the report page
28485}</div></details>
28486 <p class="curl-heading">Example</p>
28487 <div class="curl-wrap">
28488 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
28489 -H "Authorization: Bearer $SLOC_API_KEY" \
28490 -H "Content-Type: application/json" \
28491 -d @result.json \
28492 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
28493 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
28494 </div>
28495 </div>
28496 </div>
28497 </div>
28498
28499 <!-- Artifact Download -->
28500 <div class="section">
28501 <h2 class="section-title">Artifact Download</h2>
28502
28503 <div class="ep-card">
28504 <div class="ep-header">
28505 <span class="method get">GET</span>
28506 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
28507 <span class="auth-badge protected">Protected</span>
28508 <span class="ep-desc">Download or view a scan artifact</span>
28509 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28510 </div>
28511 <div class="ep-body">
28512 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
28513 <p class="params-heading">Path Parameters</p>
28514 <table class="params">
28515 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28516 <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>
28517 <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>
28518 </table>
28519 <p class="params-heading">Query Parameters</p>
28520 <table class="params">
28521 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28522 <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>
28523 </table>
28524 <p class="curl-heading">Example — download JSON result</p>
28525 <div class="curl-wrap">
28526 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28527 -o result.json \
28528 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
28529 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
28530 </div>
28531 </div>
28532 </div>
28533 </div>
28534
28535 <!-- Embed Widget -->
28536 <div class="section">
28537 <h2 class="section-title">Embed Widget</h2>
28538
28539 <div class="ep-card">
28540 <div class="ep-header">
28541 <span class="method get">GET</span>
28542 <span class="ep-path">/embed/summary</span>
28543 <span class="auth-badge protected">Protected</span>
28544 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
28545 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28546 </div>
28547 <div class="ep-body">
28548 <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>
28549 <p class="params-heading">Query Parameters</p>
28550 <table class="params">
28551 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28552 <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>
28553 <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>
28554 </table>
28555 <p class="curl-heading">Example</p>
28556 <div class="curl-wrap">
28557 <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"
28558 width="460" height="260" style="border:none"></iframe></pre>
28559 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
28560 </div>
28561 </div>
28562 </div>
28563 </div>
28564
28565 <!-- Confluence Integration -->
28566 <div class="section">
28567 <h2 class="section-title">Confluence Integration</h2>
28568
28569 <div class="ep-card">
28570 <div class="ep-header">
28571 <span class="method get">GET</span>
28572 <span class="ep-path">/api/confluence/config</span>
28573 <span class="auth-badge protected">Protected</span>
28574 <span class="ep-desc">Get current Confluence configuration</span>
28575 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28576 </div>
28577 <div class="ep-body">
28578 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
28579 <details class="schema"><summary>Response schema</summary>
28580<div class="schema-block">{
28581 "configured": boolean,
28582 "tier": "cloud" | "server",
28583 "base_url": string,
28584 "username": string,
28585 "api_token_set": boolean,
28586 "space_key": string,
28587 "parent_page_id": string | null,
28588 "schedule_auto_post": { "<schedule_id>": boolean }
28589}</div></details>
28590 <p class="curl-heading">Example</p>
28591 <div class="curl-wrap">
28592 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28593 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28594 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
28595 </div>
28596 </div>
28597 </div>
28598
28599 <div class="ep-card">
28600 <div class="ep-header">
28601 <span class="method post">POST</span>
28602 <span class="ep-path">/api/confluence/config</span>
28603 <span class="auth-badge protected">Protected</span>
28604 <span class="ep-desc">Save Confluence configuration</span>
28605 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28606 </div>
28607 <div class="ep-body">
28608 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
28609 <p class="params-heading">Request Body (application/json)</p>
28610 <table class="params">
28611 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28612 <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>
28613 <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>
28614 <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>
28615 <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>
28616 <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>
28617 <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>
28618 <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>
28619 </table>
28620 <details class="schema"><summary>Response schema</summary>
28621<div class="schema-block">{ "ok": true }</div></details>
28622 <p class="curl-heading">Example</p>
28623 <div class="curl-wrap">
28624 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
28625 -H "Authorization: Bearer $SLOC_API_KEY" \
28626 -H "Content-Type: application/json" \
28627 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
28628 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
28629 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
28630 </div>
28631 </div>
28632 </div>
28633
28634 <div class="ep-card">
28635 <div class="ep-header">
28636 <span class="method post">POST</span>
28637 <span class="ep-path">/api/confluence/test</span>
28638 <span class="auth-badge protected">Protected</span>
28639 <span class="ep-desc">Test Confluence connection</span>
28640 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28641 </div>
28642 <div class="ep-body">
28643 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
28644 <details class="schema"><summary>Response schema</summary>
28645<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
28646 <p class="curl-heading">Example</p>
28647 <div class="curl-wrap">
28648 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
28649 -H "Authorization: Bearer $SLOC_API_KEY" \
28650 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
28651 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
28652 </div>
28653 </div>
28654 </div>
28655
28656 <div class="ep-card">
28657 <div class="ep-header">
28658 <span class="method post">POST</span>
28659 <span class="ep-path">/api/confluence/post</span>
28660 <span class="auth-badge protected">Protected</span>
28661 <span class="ep-desc">Publish a scan report to Confluence</span>
28662 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28663 </div>
28664 <div class="ep-body">
28665 <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>
28666 <p class="params-heading">Request Body (application/json)</p>
28667 <table class="params">
28668 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28669 <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>
28670 <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>
28671 <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>
28672 </table>
28673 <details class="schema"><summary>Response schema</summary>
28674<div class="schema-block">// 200 OK
28675{ "ok": true, "page_id": string }
28676
28677// 400 / 502 on error
28678{ "ok": false, "error": string }</div></details>
28679 <p class="curl-heading">Example</p>
28680 <div class="curl-wrap">
28681 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
28682 -H "Authorization: Bearer $SLOC_API_KEY" \
28683 -H "Content-Type: application/json" \
28684 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
28685 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
28686 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
28687 </div>
28688 </div>
28689 </div>
28690
28691 <div class="ep-card">
28692 <div class="ep-header">
28693 <span class="method get">GET</span>
28694 <span class="ep-path">/api/confluence/wiki-markup</span>
28695 <span class="auth-badge protected">Protected</span>
28696 <span class="ep-desc">Get Confluence wiki markup for a run</span>
28697 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28698 </div>
28699 <div class="ep-body">
28700 <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>
28701 <p class="params-heading">Query Parameters</p>
28702 <table class="params">
28703 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28704 <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>
28705 </table>
28706 <p class="curl-heading">Example</p>
28707 <div class="curl-wrap">
28708 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28709 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
28710 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
28711 </div>
28712 </div>
28713 </div>
28714 </div>
28715
28716 <!-- Authentication -->
28717 <div class="section">
28718 <h2 class="section-title">Authentication</h2>
28719 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
28720
28721 <div class="ep-card">
28722 <div class="ep-header">
28723 <span class="method get">GET</span>
28724 <span class="ep-path">/auth/login</span>
28725 <span class="auth-badge public">Public</span>
28726 <span class="ep-desc">Login page</span>
28727 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28728 </div>
28729 <div class="ep-body">
28730 <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>
28731 <p class="params-heading">Query Parameters</p>
28732 <table class="params">
28733 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28734 <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>
28735 <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>
28736 </table>
28737 </div>
28738 </div>
28739
28740 <div class="ep-card">
28741 <div class="ep-header">
28742 <span class="method post">POST</span>
28743 <span class="ep-path">/auth/login</span>
28744 <span class="auth-badge public">Public</span>
28745 <span class="ep-desc">Submit credentials and get a session cookie</span>
28746 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28747 </div>
28748 <div class="ep-body">
28749 <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>
28750 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
28751 <table class="params">
28752 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
28753 <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>
28754 <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>
28755 </table>
28756 <p class="curl-heading">Example</p>
28757 <div class="curl-wrap">
28758 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
28759 -d "key=$SLOC_API_KEY&next=/" \
28760 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
28761 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
28762 </div>
28763 </div>
28764 </div>
28765 </div>
28766
28767 <!-- Coverage Suggestion -->
28768 <div class="section">
28769 <h2 class="section-title">Coverage Suggestion</h2>
28770
28771 <div class="ep-card">
28772 <div class="ep-header">
28773 <span class="method get">GET</span>
28774 <span class="ep-path">/api/suggest-coverage</span>
28775 <span class="auth-badge protected">Protected</span>
28776 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
28777 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
28778 </div>
28779 <div class="ep-body">
28780 <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>
28781 <p class="params-heading">Query Parameters</p>
28782 <table class="params">
28783 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
28784 <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>
28785 </table>
28786 <details class="schema"><summary>Response schema</summary>
28787<div class="schema-block">{
28788 "found": string | null, // absolute path to the coverage file, if detected
28789 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
28790 "hint": string | null // shell command to generate coverage if not found
28791}</div></details>
28792 <p class="curl-heading">Example</p>
28793 <div class="curl-wrap">
28794 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
28795 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
28796 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
28797 </div>
28798 </div>
28799 </div>
28800 </div>
28801
28802 </div>
28803
28804 <footer class="site-footer">
28805 local code analysis - metrics, history and reports
28806 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28807 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28808 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28809 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28810 · <a href="/api-docs" rel="noopener">REST API</a>
28811 </footer>
28812
28813 <script nonce="{{ csp_nonce }}">
28814 (function () {
28815 var base = window.location.origin;
28816 document.getElementById('base-url').textContent = base;
28817 document.querySelectorAll('.base-url-slot').forEach(function (el) {
28818 el.textContent = base;
28819 });
28820
28821 document.querySelectorAll('.ep-header').forEach(function (hdr) {
28822 hdr.addEventListener('click', function () {
28823 hdr.closest('.ep-card').classList.toggle('open');
28824 });
28825 });
28826
28827 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
28828 btn.addEventListener('click', function () {
28829 var targetId = btn.dataset.target;
28830 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
28831 if (!pre) return;
28832 navigator.clipboard.writeText(pre.textContent).then(function () {
28833 btn.textContent = 'Copied!';
28834 btn.classList.add('copied');
28835 setTimeout(function () {
28836 btn.textContent = 'Copy';
28837 btn.classList.remove('copied');
28838 }, 2000);
28839 });
28840 });
28841 });
28842
28843 var storageKey = 'oxide-sloc-theme';
28844 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
28845 var themeBtn = document.getElementById('theme-toggle');
28846 if (themeBtn) {
28847 themeBtn.addEventListener('click', function () {
28848 var dark = document.body.classList.toggle('dark-theme');
28849 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
28850 });
28851 }
28852 (function() {
28853 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'}];
28854 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);});}
28855 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28856 var btn=document.getElementById('settings-btn');if(!btn)return;
28857 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28858 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>';
28859 document.body.appendChild(m);
28860 var g=document.getElementById('scheme-grid');
28861 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);});
28862 var cl=document.getElementById('settings-close');
28863 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);
28864 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');});
28865 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28866 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28867 })();
28868 (function randomizeWatermarks() {
28869 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28870 if (!wms.length) return;
28871 var placed = [];
28872 function tooClose(top, left) {
28873 for (var i = 0; i < placed.length; i++) {
28874 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
28875 if (dt < 16 && dl < 12) return true;
28876 }
28877 return false;
28878 }
28879 function pick(leftBand) {
28880 for (var attempt = 0; attempt < 50; attempt++) {
28881 var top = Math.random() * 88 + 2;
28882 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28883 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28884 }
28885 var top = Math.random() * 88 + 2;
28886 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
28887 placed.push([top, left]); return [top, left];
28888 }
28889 var half = Math.floor(wms.length / 2);
28890 wms.forEach(function (img, i) {
28891 var pos = pick(i < half);
28892 var size = Math.floor(Math.random() * 100 + 120);
28893 var rot = (Math.random() * 360).toFixed(1);
28894 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
28895 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;
28896 });
28897 })();
28898 (function spawnCodeParticles() {
28899 var container = document.getElementById('code-particles');
28900 if (!container) return;
28901 var snippets = [
28902 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
28903 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
28904 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
28905 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
28906 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
28907 ];
28908 var count = 38;
28909 for (var i = 0; i < count; i++) {
28910 (function(idx) {
28911 var el = document.createElement('span');
28912 el.className = 'code-particle';
28913 el.textContent = snippets[idx % snippets.length];
28914 var left = Math.random() * 94 + 2;
28915 var top = Math.random() * 88 + 6;
28916 var dur = (Math.random() * 10 + 9).toFixed(1);
28917 var delay = (Math.random() * 18).toFixed(1);
28918 var rot = (Math.random() * 26 - 13).toFixed(1);
28919 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28920 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
28921 container.appendChild(el);
28922 })(i);
28923 }
28924 })();
28925 }());
28926 </script>
28927</body>
28928</html>
28929"##,
28930 ext = "html"
28931)]
28932struct ApiDocsTemplate {
28933 has_api_key: bool,
28934 csp_nonce: String,
28935 version: &'static str,
28936}
28937
28938#[cfg(test)]
28939mod form_config_tests {
28940 use super::*;
28941 use sloc_config::{
28942 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
28943 };
28944
28945 fn blank_form() -> AnalyzeForm {
28946 AnalyzeForm {
28947 path: ".".to_string(),
28948 git_repo: None,
28949 git_ref: None,
28950 mixed_line_policy: None,
28951 python_docstrings_as_comments: None,
28952 generated_file_detection: None,
28953 minified_file_detection: None,
28954 vendor_directory_detection: None,
28955 include_lockfiles: None,
28956 binary_file_behavior: None,
28957 output_dir: None,
28958 report_title: None,
28959 report_header_footer: None,
28960 include_globs: None,
28961 exclude_globs: None,
28962 submodule_breakdown: None,
28963 coverage_file: None,
28964 continuation_line_policy: None,
28965 blank_in_block_comment_policy: None,
28966 count_compiler_directives: None,
28967 style_col_threshold: None,
28968 style_analysis_enabled: None,
28969 style_score_threshold: None,
28970 style_lang_scope: None,
28971 cocomo_mode: None,
28972 complexity_alert: None,
28973 exclude_duplicates: None,
28974 }
28975 }
28976
28977 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
28978 let mut cfg = sloc_config::AppConfig::default();
28979 apply_form_to_config(&mut cfg, form);
28980 cfg
28981 }
28982
28983 #[test]
28986 fn python_docstrings_false_when_unchecked() {
28987 let cfg = apply(&blank_form());
28989 assert!(
28990 !cfg.analysis.python_docstrings_as_comments,
28991 "absent python_docstrings_as_comments must map to false"
28992 );
28993 }
28994
28995 #[test]
28996 fn python_docstrings_true_when_checked() {
28997 let mut form = blank_form();
28999 form.python_docstrings_as_comments = Some("on".to_string());
29000 let cfg = apply(&form);
29001 assert!(cfg.analysis.python_docstrings_as_comments);
29002 }
29003
29004 #[test]
29005 fn python_docstrings_true_for_any_non_none_value() {
29006 let mut form = blank_form();
29008 form.python_docstrings_as_comments = Some("true".to_string());
29009 assert!(apply(&form).analysis.python_docstrings_as_comments);
29010 }
29011
29012 #[test]
29015 fn submodule_breakdown_false_when_unchecked() {
29016 let cfg = apply(&blank_form());
29017 assert!(
29018 !cfg.discovery.submodule_breakdown,
29019 "absent submodule_breakdown must map to false"
29020 );
29021 }
29022
29023 #[test]
29024 fn submodule_breakdown_true_when_value_enabled() {
29025 let mut form = blank_form();
29026 form.submodule_breakdown = Some("enabled".to_string());
29027 assert!(apply(&form).discovery.submodule_breakdown);
29028 }
29029
29030 #[test]
29031 fn submodule_breakdown_false_for_wrong_value() {
29032 let mut form = blank_form();
29034 form.submodule_breakdown = Some("on".to_string());
29035 assert!(
29036 !apply(&form).discovery.submodule_breakdown,
29037 "submodule_breakdown only becomes true for the exact value 'enabled'"
29038 );
29039 }
29040
29041 #[test]
29044 fn generated_detection_true_when_enabled() {
29045 let mut form = blank_form();
29046 form.generated_file_detection = Some("enabled".to_string());
29047 assert!(apply(&form).analysis.generated_file_detection);
29048 }
29049
29050 #[test]
29051 fn generated_detection_false_when_disabled() {
29052 let mut form = blank_form();
29053 form.generated_file_detection = Some("disabled".to_string());
29054 assert!(!apply(&form).analysis.generated_file_detection);
29055 }
29056
29057 #[test]
29058 fn generated_detection_true_when_absent() {
29059 assert!(
29061 apply(&blank_form()).analysis.generated_file_detection,
29062 "absent field must default to true (detection on)"
29063 );
29064 }
29065
29066 #[test]
29069 fn minified_detection_false_when_disabled() {
29070 let mut form = blank_form();
29071 form.minified_file_detection = Some("disabled".to_string());
29072 assert!(!apply(&form).analysis.minified_file_detection);
29073 }
29074
29075 #[test]
29076 fn minified_detection_true_when_enabled() {
29077 let mut form = blank_form();
29078 form.minified_file_detection = Some("enabled".to_string());
29079 assert!(apply(&form).analysis.minified_file_detection);
29080 }
29081
29082 #[test]
29083 fn minified_detection_true_when_absent() {
29084 assert!(apply(&blank_form()).analysis.minified_file_detection);
29085 }
29086
29087 #[test]
29090 fn vendor_detection_false_when_disabled() {
29091 let mut form = blank_form();
29092 form.vendor_directory_detection = Some("disabled".to_string());
29093 assert!(!apply(&form).analysis.vendor_directory_detection);
29094 }
29095
29096 #[test]
29097 fn vendor_detection_true_when_enabled() {
29098 let mut form = blank_form();
29099 form.vendor_directory_detection = Some("enabled".to_string());
29100 assert!(apply(&form).analysis.vendor_directory_detection);
29101 }
29102
29103 #[test]
29104 fn vendor_detection_true_when_absent() {
29105 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
29106 }
29107
29108 #[test]
29111 fn lockfiles_false_when_absent() {
29112 assert!(!apply(&blank_form()).analysis.include_lockfiles);
29114 }
29115
29116 #[test]
29117 fn lockfiles_false_when_disabled() {
29118 let mut form = blank_form();
29119 form.include_lockfiles = Some("disabled".to_string());
29120 assert!(!apply(&form).analysis.include_lockfiles);
29121 }
29122
29123 #[test]
29124 fn lockfiles_true_when_enabled() {
29125 let mut form = blank_form();
29126 form.include_lockfiles = Some("enabled".to_string());
29127 assert!(apply(&form).analysis.include_lockfiles);
29128 }
29129
29130 #[test]
29133 fn compiler_directives_true_when_absent() {
29134 assert!(
29135 apply(&blank_form()).analysis.count_compiler_directives,
29136 "absent count_compiler_directives must default to true"
29137 );
29138 }
29139
29140 #[test]
29141 fn compiler_directives_true_when_enabled() {
29142 let mut form = blank_form();
29143 form.count_compiler_directives = Some("enabled".to_string());
29144 assert!(apply(&form).analysis.count_compiler_directives);
29145 }
29146
29147 #[test]
29148 fn compiler_directives_false_when_disabled() {
29149 let mut form = blank_form();
29150 form.count_compiler_directives = Some("disabled".to_string());
29151 assert!(!apply(&form).analysis.count_compiler_directives);
29152 }
29153
29154 #[test]
29157 fn mixed_policy_unchanged_when_absent() {
29158 assert_eq!(
29160 apply(&blank_form()).analysis.mixed_line_policy,
29161 MixedLinePolicy::CodeOnly
29162 );
29163 }
29164
29165 #[test]
29166 fn mixed_policy_code_only() {
29167 let mut form = blank_form();
29168 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
29169 assert_eq!(
29170 apply(&form).analysis.mixed_line_policy,
29171 MixedLinePolicy::CodeOnly
29172 );
29173 }
29174
29175 #[test]
29176 fn mixed_policy_code_and_comment() {
29177 let mut form = blank_form();
29178 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
29179 assert_eq!(
29180 apply(&form).analysis.mixed_line_policy,
29181 MixedLinePolicy::CodeAndComment
29182 );
29183 }
29184
29185 #[test]
29186 fn mixed_policy_comment_only() {
29187 let mut form = blank_form();
29188 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
29189 assert_eq!(
29190 apply(&form).analysis.mixed_line_policy,
29191 MixedLinePolicy::CommentOnly
29192 );
29193 }
29194
29195 #[test]
29196 fn mixed_policy_separate_mixed_category() {
29197 let mut form = blank_form();
29198 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
29199 assert_eq!(
29200 apply(&form).analysis.mixed_line_policy,
29201 MixedLinePolicy::SeparateMixedCategory
29202 );
29203 }
29204
29205 #[test]
29208 fn binary_behavior_skip_when_absent() {
29209 assert_eq!(
29210 apply(&blank_form()).analysis.binary_file_behavior,
29211 BinaryFileBehavior::Skip
29212 );
29213 }
29214
29215 #[test]
29216 fn binary_behavior_skip() {
29217 let mut form = blank_form();
29218 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
29219 assert_eq!(
29220 apply(&form).analysis.binary_file_behavior,
29221 BinaryFileBehavior::Skip
29222 );
29223 }
29224
29225 #[test]
29226 fn binary_behavior_fail() {
29227 let mut form = blank_form();
29228 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
29229 assert_eq!(
29230 apply(&form).analysis.binary_file_behavior,
29231 BinaryFileBehavior::Fail
29232 );
29233 }
29234
29235 #[test]
29238 fn continuation_policy_each_physical_when_absent() {
29239 assert_eq!(
29240 apply(&blank_form()).analysis.continuation_line_policy,
29241 ContinuationLinePolicy::EachPhysicalLine
29242 );
29243 }
29244
29245 #[test]
29246 fn continuation_policy_collapse_to_logical() {
29247 let mut form = blank_form();
29248 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
29249 assert_eq!(
29250 apply(&form).analysis.continuation_line_policy,
29251 ContinuationLinePolicy::CollapseToLogical
29252 );
29253 }
29254
29255 #[test]
29258 fn blank_in_block_comment_count_as_comment_when_absent() {
29259 assert_eq!(
29260 apply(&blank_form()).analysis.blank_in_block_comment_policy,
29261 BlankInBlockCommentPolicy::CountAsComment
29262 );
29263 }
29264
29265 #[test]
29266 fn blank_in_block_comment_count_as_blank() {
29267 let mut form = blank_form();
29268 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
29269 assert_eq!(
29270 apply(&form).analysis.blank_in_block_comment_policy,
29271 BlankInBlockCommentPolicy::CountAsBlank
29272 );
29273 }
29274
29275 #[test]
29278 fn style_threshold_80() {
29279 let mut form = blank_form();
29280 form.style_col_threshold = Some("80".to_string());
29281 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
29282 }
29283
29284 #[test]
29285 fn style_threshold_100() {
29286 let mut form = blank_form();
29287 form.style_col_threshold = Some("100".to_string());
29288 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
29289 }
29290
29291 #[test]
29292 fn style_threshold_120() {
29293 let mut form = blank_form();
29294 form.style_col_threshold = Some("120".to_string());
29295 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
29296 }
29297
29298 #[test]
29299 fn style_threshold_invalid_value_leaves_default() {
29300 let mut cfg = sloc_config::AppConfig::default();
29302 let mut form = blank_form();
29303 form.style_col_threshold = Some("42".to_string());
29304 apply_form_to_config(&mut cfg, &form);
29305 assert_eq!(
29306 cfg.analysis.style_col_threshold, 80,
29307 "invalid threshold must not change config"
29308 );
29309 }
29310
29311 #[test]
29312 fn style_threshold_non_numeric_leaves_default() {
29313 let mut cfg = sloc_config::AppConfig::default();
29314 let mut form = blank_form();
29315 form.style_col_threshold = Some("large".to_string());
29316 apply_form_to_config(&mut cfg, &form);
29317 assert_eq!(cfg.analysis.style_col_threshold, 80);
29318 }
29319
29320 #[test]
29321 fn style_threshold_zero_leaves_default() {
29322 let mut cfg = sloc_config::AppConfig::default();
29323 let mut form = blank_form();
29324 form.style_col_threshold = Some("0".to_string());
29325 apply_form_to_config(&mut cfg, &form);
29326 assert_eq!(cfg.analysis.style_col_threshold, 80);
29327 }
29328
29329 #[test]
29330 fn style_threshold_absent_leaves_default() {
29331 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
29332 }
29333
29334 #[test]
29337 fn coverage_file_none_when_absent() {
29338 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
29339 }
29340
29341 #[test]
29342 fn coverage_file_none_when_whitespace_only() {
29343 let mut form = blank_form();
29344 form.coverage_file = Some(" ".to_string());
29345 assert!(
29346 apply(&form).analysis.coverage_file.is_none(),
29347 "whitespace-only coverage_file must be treated as None"
29348 );
29349 }
29350
29351 #[test]
29352 fn coverage_file_set_when_non_empty() {
29353 let mut form = blank_form();
29354 form.coverage_file = Some("coverage/lcov.info".to_string());
29355 assert_eq!(
29356 apply(&form).analysis.coverage_file,
29357 Some(std::path::PathBuf::from("coverage/lcov.info"))
29358 );
29359 }
29360
29361 #[test]
29362 fn coverage_file_trims_whitespace() {
29363 let mut form = blank_form();
29364 form.coverage_file = Some(" coverage/lcov.info ".to_string());
29365 assert_eq!(
29366 apply(&form).analysis.coverage_file,
29367 Some(std::path::PathBuf::from("coverage/lcov.info"))
29368 );
29369 }
29370
29371 #[test]
29374 fn report_title_unchanged_when_absent() {
29375 let original = sloc_config::AppConfig::default().reporting.report_title;
29376 assert_eq!(apply(&blank_form()).reporting.report_title, original);
29377 }
29378
29379 #[test]
29380 fn report_title_unchanged_when_whitespace_only() {
29381 let original = sloc_config::AppConfig::default().reporting.report_title;
29382 let mut form = blank_form();
29383 form.report_title = Some(" ".to_string());
29384 assert_eq!(
29385 apply(&form).reporting.report_title,
29386 original,
29387 "whitespace-only title must not overwrite the default"
29388 );
29389 }
29390
29391 #[test]
29392 fn report_title_updated_and_trimmed() {
29393 let mut form = blank_form();
29394 form.report_title = Some(" My Project ".to_string());
29395 assert_eq!(apply(&form).reporting.report_title, "My Project");
29396 }
29397
29398 #[test]
29401 fn header_footer_none_when_absent() {
29402 assert!(apply(&blank_form())
29403 .reporting
29404 .report_header_footer
29405 .is_none());
29406 }
29407
29408 #[test]
29409 fn header_footer_none_when_whitespace_only() {
29410 let mut form = blank_form();
29411 form.report_header_footer = Some(" ".to_string());
29412 assert!(apply(&form).reporting.report_header_footer.is_none());
29413 }
29414
29415 #[test]
29416 fn header_footer_set_and_trimmed() {
29417 let mut form = blank_form();
29418 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
29419 assert_eq!(
29420 apply(&form).reporting.report_header_footer,
29421 Some("Confidential — Internal Use".to_string())
29422 );
29423 }
29424
29425 #[test]
29428 fn include_globs_empty_when_absent() {
29429 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
29430 }
29431
29432 #[test]
29433 fn include_globs_newline_separated() {
29434 let mut form = blank_form();
29435 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
29436 assert_eq!(
29437 apply(&form).discovery.include_globs,
29438 vec!["src/**/*.rs", "tests/**/*.rs"]
29439 );
29440 }
29441
29442 #[test]
29443 fn exclude_globs_comma_separated() {
29444 let mut form = blank_form();
29445 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
29446 assert_eq!(
29447 apply(&form).discovery.exclude_globs,
29448 vec!["vendor/**", "node_modules/**"]
29449 );
29450 }
29451
29452 #[test]
29453 fn globs_mixed_separators() {
29454 let mut form = blank_form();
29455 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
29456 assert_eq!(
29457 apply(&form).discovery.exclude_globs,
29458 vec!["a/**", "b/**", "c/**"]
29459 );
29460 }
29461
29462 #[test]
29465 fn split_patterns_none_is_empty() {
29466 assert!(split_patterns(None).is_empty());
29467 }
29468
29469 #[test]
29470 fn split_patterns_empty_string_is_empty() {
29471 assert!(split_patterns(Some("")).is_empty());
29472 }
29473
29474 #[test]
29475 fn split_patterns_whitespace_only_is_empty() {
29476 assert!(split_patterns(Some(" \n \n ")).is_empty());
29477 }
29478
29479 #[test]
29480 fn split_patterns_newlines() {
29481 assert_eq!(
29482 split_patterns(Some("a/**\nb/**\nc/**")),
29483 vec!["a/**", "b/**", "c/**"]
29484 );
29485 }
29486
29487 #[test]
29488 fn split_patterns_commas() {
29489 assert_eq!(
29490 split_patterns(Some("a/**,b/**,c/**")),
29491 vec!["a/**", "b/**", "c/**"]
29492 );
29493 }
29494
29495 #[test]
29496 fn split_patterns_mixed() {
29497 assert_eq!(
29498 split_patterns(Some("a/**\nb/**,c/**")),
29499 vec!["a/**", "b/**", "c/**"]
29500 );
29501 }
29502
29503 #[test]
29504 fn split_patterns_trims_whitespace() {
29505 assert_eq!(
29506 split_patterns(Some(" a/** \n b/** ")),
29507 vec!["a/**", "b/**"]
29508 );
29509 }
29510
29511 #[test]
29512 fn split_patterns_filters_empty_entries() {
29513 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
29514 }
29515
29516 #[test]
29517 fn split_patterns_single_entry() {
29518 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
29519 }
29520}
29521
29522#[cfg(test)]
29523mod utility_tests {
29524 use super::*;
29525 use std::net::IpAddr;
29526 use std::time::Duration;
29527
29528 #[test]
29531 fn sanitize_simple_name() {
29532 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
29533 }
29534
29535 #[test]
29536 fn sanitize_uppercased_lowercased() {
29537 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
29538 }
29539
29540 #[test]
29541 fn sanitize_path_extracts_filename() {
29542 assert_eq!(
29543 sanitize_project_label("/home/user/my-project"),
29544 "my-project"
29545 );
29546 }
29547
29548 #[test]
29549 fn sanitize_path_uses_last_component() {
29550 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
29551 }
29552
29553 #[test]
29554 fn sanitize_spaces_become_hyphens() {
29555 assert_eq!(sanitize_project_label("my project"), "my-project");
29556 }
29557
29558 #[test]
29559 fn sanitize_non_ascii_become_hyphens() {
29560 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
29561 }
29562
29563 #[test]
29564 fn sanitize_all_special_chars_gives_project() {
29565 assert_eq!(sanitize_project_label("!@#$%^"), "project");
29566 }
29567
29568 #[test]
29569 fn sanitize_empty_string_gives_project() {
29570 assert_eq!(sanitize_project_label(""), "project");
29571 }
29572
29573 #[test]
29574 fn sanitize_leading_trailing_hyphens_stripped() {
29575 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
29576 }
29577
29578 #[test]
29579 fn sanitize_alphanumeric_preserved() {
29580 assert_eq!(sanitize_project_label("repo123"), "repo123");
29581 }
29582
29583 #[test]
29584 fn sanitize_dots_become_hyphens() {
29585 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
29586 }
29587
29588 #[test]
29589 fn sanitize_mixed_slashes_uses_filename() {
29590 assert_eq!(sanitize_project_label("project-name"), "project-name");
29592 }
29593
29594 #[test]
29597 fn rate_limiter_allows_first_request() {
29598 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
29599 let ip: IpAddr = "127.0.0.1".parse().unwrap();
29600 assert!(rl.is_allowed(ip));
29601 }
29602
29603 #[test]
29604 fn rate_limiter_blocks_after_limit_reached() {
29605 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
29606 let ip: IpAddr = "10.0.0.1".parse().unwrap();
29607 assert!(rl.is_allowed(ip));
29608 assert!(rl.is_allowed(ip));
29609 assert!(rl.is_allowed(ip));
29610 assert!(!rl.is_allowed(ip), "4th request must be blocked");
29611 }
29612
29613 #[test]
29614 fn rate_limiter_allows_requests_up_to_limit() {
29615 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
29616 let ip: IpAddr = "10.0.0.2".parse().unwrap();
29617 for _ in 0..5 {
29618 assert!(rl.is_allowed(ip));
29619 }
29620 assert!(!rl.is_allowed(ip), "6th request must be blocked");
29621 }
29622
29623 #[test]
29624 fn rate_limiter_different_ips_are_independent() {
29625 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
29626 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
29627 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
29628 assert!(rl.is_allowed(ip1));
29629 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
29630 assert!(rl.is_allowed(ip2), "ip2 must be independent");
29631 }
29632
29633 #[test]
29634 fn rate_limiter_auth_failure_not_locked_below_threshold() {
29635 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29636 let ip: IpAddr = "10.0.0.3".parse().unwrap();
29637 rl.record_auth_failure(ip);
29638 rl.record_auth_failure(ip);
29639 assert!(
29640 !rl.is_auth_locked_out(ip),
29641 "not locked at 2 failures when threshold is 3"
29642 );
29643 }
29644
29645 #[test]
29646 fn rate_limiter_auth_failure_locked_at_threshold() {
29647 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
29648 let ip: IpAddr = "10.0.0.4".parse().unwrap();
29649 rl.record_auth_failure(ip);
29650 rl.record_auth_failure(ip);
29651 rl.record_auth_failure(ip);
29652 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
29653 }
29654
29655 #[test]
29656 fn rate_limiter_auth_failure_different_ips_independent() {
29657 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
29658 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
29659 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
29660 rl.record_auth_failure(ip1);
29661 rl.record_auth_failure(ip1);
29662 assert!(rl.is_auth_locked_out(ip1));
29663 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
29664 }
29665
29666 #[test]
29667 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
29668 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
29669 let ip: IpAddr = "127.0.0.2".parse().unwrap();
29670 for _ in 0..100 {
29671 assert!(rl.is_allowed(ip));
29672 }
29673 }
29674
29675 #[test]
29678 fn strip_unc_plain_path_unchanged() {
29679 let p = PathBuf::from("C:\\Users\\user\\project");
29680 let result = strip_unc_prefix(p.clone());
29681 assert_eq!(result, p);
29682 }
29683
29684 #[test]
29685 fn strip_unc_with_drive_prefix_stripped() {
29686 let p = PathBuf::from(r"\\?\C:\Users\user\project");
29687 let result = strip_unc_prefix(p);
29688 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
29689 }
29690
29691 #[test]
29692 fn strip_unc_with_network_prefix_stripped() {
29693 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
29694 let result = strip_unc_prefix(p);
29695 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
29696 }
29697
29698 #[test]
29699 fn strip_unc_linux_path_unchanged() {
29700 let p = PathBuf::from("/home/user/project");
29701 let result = strip_unc_prefix(p.clone());
29702 assert_eq!(result, p);
29703 }
29704
29705 #[test]
29708 fn remote_to_commit_url_github_https() {
29709 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
29710 assert_eq!(
29711 url,
29712 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29713 );
29714 }
29715
29716 #[test]
29717 fn remote_to_commit_url_github_ssh() {
29718 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
29719 assert_eq!(
29720 url,
29721 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
29722 );
29723 }
29724
29725 #[test]
29726 fn remote_to_commit_url_gitlab_uses_dash_commit() {
29727 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
29728 assert_eq!(
29729 url,
29730 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
29731 );
29732 }
29733
29734 #[test]
29735 fn remote_to_commit_url_bitbucket_uses_commits() {
29736 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
29737 assert_eq!(
29738 url,
29739 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
29740 );
29741 }
29742
29743 #[test]
29744 fn remote_to_commit_url_unknown_scheme_returns_none() {
29745 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
29746 assert!(url.is_none());
29747 }
29748
29749 #[test]
29750 fn remote_to_commit_url_ssh_gitlab() {
29751 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
29752 assert!(url.is_some());
29753 let u = url.unwrap();
29754 assert!(
29755 u.contains("/-/commit/sha123"),
29756 "gitlab ssh must use /-/commit/"
29757 );
29758 }
29759
29760 #[test]
29763 fn git_clone_dest_github_url_produces_safe_name() {
29764 let dir = PathBuf::from("/tmp/clones");
29765 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29766 let name = dest.file_name().unwrap().to_string_lossy();
29767 assert!(!name.is_empty());
29768 assert!(
29769 name.chars()
29770 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
29771 "clone dest must only contain safe chars, got: {name}"
29772 );
29773 }
29774
29775 #[test]
29776 fn git_clone_dest_is_inside_clones_dir() {
29777 let dir = PathBuf::from("/tmp/clones");
29778 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
29779 assert!(
29780 dest.starts_with(&dir),
29781 "clone dest must be inside clones_dir"
29782 );
29783 }
29784
29785 #[test]
29786 fn git_clone_dest_truncates_to_80_chars_max() {
29787 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
29788 let dir = PathBuf::from("/tmp/clones");
29789 let dest = git_clone_dest(&long_url, &dir);
29790 let name = dest.file_name().unwrap().to_string_lossy();
29791 assert!(
29792 name.len() <= 80,
29793 "clone dest name must be at most 80 chars, got {} chars: {name}",
29794 name.len()
29795 );
29796 }
29797
29798 #[test]
29799 fn git_clone_dest_special_chars_replaced_with_underscore() {
29800 let dir = PathBuf::from("/tmp/clones");
29801 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
29802 let name = dest.file_name().unwrap().to_string_lossy();
29803 assert!(
29804 !name.contains('@') && !name.contains(':') && !name.contains('/'),
29805 "special chars must be replaced in clone dest, got: {name}"
29806 );
29807 }
29808
29809 #[test]
29810 fn git_clone_dest_different_urls_differ() {
29811 let dir = PathBuf::from("/tmp/clones");
29812 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
29813 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
29814 assert_ne!(
29815 a, b,
29816 "different repos must produce different clone dest names"
29817 );
29818 }
29819
29820 #[test]
29821 fn git_clone_dest_same_url_same_result() {
29822 let dir = PathBuf::from("/tmp/clones");
29823 let url = "https://github.com/owner/repo.git";
29824 assert_eq!(
29825 git_clone_dest(url, &dir),
29826 git_clone_dest(url, &dir),
29827 "same URL must always give same clone dest"
29828 );
29829 }
29830
29831 #[test]
29834 fn fmt_delta_positive_has_plus_prefix() {
29835 assert_eq!(fmt_delta(5), "+5");
29836 }
29837
29838 #[test]
29839 fn fmt_delta_negative_no_plus_prefix() {
29840 assert_eq!(fmt_delta(-3), "-3");
29841 }
29842
29843 #[test]
29844 fn fmt_delta_zero() {
29845 assert_eq!(fmt_delta(0), "0");
29846 }
29847
29848 #[test]
29851 fn delta_class_positive_is_pos() {
29852 assert_eq!(delta_class(1), "pos");
29853 }
29854
29855 #[test]
29856 fn delta_class_negative_is_neg() {
29857 assert_eq!(delta_class(-1), "neg");
29858 }
29859
29860 #[test]
29861 fn delta_class_zero_is_zero_class() {
29862 assert_eq!(delta_class(0), "zero");
29863 }
29864
29865 #[test]
29868 fn fmt_pct_zero_baseline_returns_em_dash() {
29869 assert_eq!(fmt_pct(100, 0), "\u{2014}");
29870 }
29871
29872 #[test]
29873 fn fmt_pct_positive_delta_has_plus_sign() {
29874 let result = fmt_pct(10, 100);
29875 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
29876 }
29877
29878 #[test]
29879 fn fmt_pct_negative_delta_no_plus_sign() {
29880 let result = fmt_pct(-10, 100);
29881 assert!(!result.starts_with('+'), "unexpected + in: {result}");
29882 assert!(result.contains('%'));
29883 }
29884
29885 #[test]
29886 fn fmt_pct_near_zero_returns_pm_zero() {
29887 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
29888 }
29889
29890 #[test]
29893 fn summary_delta_no_prev_returns_dash_na() {
29894 let (display, class) = summary_delta(10, None);
29895 assert_eq!(display, "\u{2014}");
29896 assert_eq!(class, "na");
29897 }
29898
29899 #[test]
29900 fn summary_delta_increase_is_positive() {
29901 let (display, class) = summary_delta(15, Some(10));
29902 assert_eq!(display, "+5");
29903 assert_eq!(class, "pos");
29904 }
29905
29906 #[test]
29907 fn summary_delta_decrease_is_negative() {
29908 let (display, class) = summary_delta(5, Some(10));
29909 assert_eq!(display, "-5");
29910 assert_eq!(class, "neg");
29911 }
29912
29913 #[test]
29916 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
29917 use chrono::Datelike;
29918 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
29919 assert_eq!(d.year(), 2024);
29920 assert_eq!(d.month(), 1);
29921 assert_eq!(d.weekday(), chrono::Weekday::Mon);
29922 assert!(d.day() <= 7);
29923 }
29924
29925 #[test]
29926 fn nth_weekday_second_sunday_march_2024_is_10th() {
29927 use chrono::Datelike;
29928 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
29929 assert_eq!(d.weekday(), chrono::Weekday::Sun);
29930 assert_eq!(d.month(), 3);
29931 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
29932 }
29933
29934 #[test]
29937 fn is_pacific_dst_july_is_true() {
29938 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29939 assert!(is_pacific_dst(dt), "July must be PDT");
29940 }
29941
29942 #[test]
29943 fn is_pacific_dst_january_is_false() {
29944 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29945 assert!(!is_pacific_dst(dt), "January must be PST");
29946 }
29947
29948 #[test]
29949 fn fmt_la_time_summer_shows_pdt() {
29950 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
29951 let result = fmt_la_time(dt);
29952 assert!(
29953 result.ends_with("PDT"),
29954 "summer must use PDT, got: {result}"
29955 );
29956 }
29957
29958 #[test]
29959 fn fmt_la_time_winter_shows_pst() {
29960 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
29961 let result = fmt_la_time(dt);
29962 assert!(
29963 result.ends_with("PST"),
29964 "winter must use PST, got: {result}"
29965 );
29966 }
29967
29968 #[test]
29969 fn fmt_la_time_meta_summer_shows_pdt() {
29970 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
29971 let result = fmt_la_time_meta(dt);
29972 assert!(
29973 result.ends_with("PDT"),
29974 "meta summer must use PDT, got: {result}"
29975 );
29976 }
29977
29978 #[test]
29979 fn fmt_la_time_meta_winter_shows_pst() {
29980 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
29981 let result = fmt_la_time_meta(dt);
29982 assert!(
29983 result.ends_with("PST"),
29984 "meta winter must use PST, got: {result}"
29985 );
29986 }
29987
29988 #[test]
29991 fn fmt_git_date_valid_iso_returns_some() {
29992 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
29993 }
29994
29995 #[test]
29996 fn fmt_git_date_invalid_returns_none() {
29997 assert!(fmt_git_date("not-a-date").is_none());
29998 }
29999
30000 #[test]
30003 fn format_number_zero() {
30004 assert_eq!(format_number(0), "0");
30005 }
30006
30007 #[test]
30008 fn format_number_three_digits_no_comma() {
30009 assert_eq!(format_number(999), "999");
30010 }
30011
30012 #[test]
30013 fn format_number_four_digits_has_comma() {
30014 assert_eq!(format_number(1000), "1,000");
30015 }
30016
30017 #[test]
30018 fn format_number_seven_digits_two_commas() {
30019 assert_eq!(format_number(1_234_567), "1,234,567");
30020 }
30021
30022 #[test]
30023 fn format_number_one_million() {
30024 assert_eq!(format_number(1_000_000), "1,000,000");
30025 }
30026
30027 #[test]
30030 fn badge_text_px_empty_is_zero() {
30031 assert_eq!(badge_text_px(""), 0);
30032 }
30033
30034 #[test]
30035 fn badge_text_px_narrow_chars_smaller_than_normal() {
30036 assert!(
30037 badge_text_px("if") < badge_text_px("ab"),
30038 "'if' must be narrower than 'ab'"
30039 );
30040 }
30041
30042 #[test]
30043 fn badge_text_px_m_is_wider_than_a() {
30044 assert!(
30045 badge_text_px("m") > badge_text_px("a"),
30046 "'m' must be wider than 'a'"
30047 );
30048 }
30049
30050 #[test]
30051 fn render_badge_svg_contains_label_and_value() {
30052 let svg = render_badge_svg("coverage", "95%", "#4c1");
30053 assert!(svg.contains("coverage") && svg.contains("95%"));
30054 }
30055
30056 #[test]
30057 fn render_badge_svg_contains_color() {
30058 let svg = render_badge_svg("sloc", "12K", "#e05d44");
30059 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
30060 }
30061
30062 #[test]
30063 fn render_badge_svg_escapes_ampersand_in_label() {
30064 let svg = render_badge_svg("test&label", "ok", "#4c1");
30065 assert!(svg.contains("&") && !svg.contains("test&label"));
30066 }
30067
30068 #[test]
30071 fn build_pdf_filename_slugifies_title() {
30072 let name = build_pdf_filename("My Project Report", "abc-def-1234");
30073 assert!(
30074 name.starts_with("my_project_report_")
30075 && std::path::Path::new(&name)
30076 .extension()
30077 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30078 );
30079 }
30080
30081 #[test]
30082 fn build_pdf_filename_uses_last_run_id_segment() {
30083 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
30084 assert!(name.contains("ABCD"), "must use last segment of run_id");
30085 }
30086
30087 #[test]
30088 fn build_pdf_filename_empty_title_uses_report_prefix() {
30089 let name = build_pdf_filename("", "abc-def-9999");
30090 assert!(
30091 name.starts_with("report_")
30092 && std::path::Path::new(&name)
30093 .extension()
30094 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
30095 );
30096 }
30097
30098 #[test]
30101 fn swap_chart_js_replaces_inline_block() {
30102 let html = "<html><head><script>// inline source</script></head><body></body></html>";
30103 let result = swap_inline_chart_js_for_static(html.to_string());
30104 assert!(result.contains(r#"src="/static/chart-report.js""#));
30105 assert!(!result.contains("inline source"));
30106 }
30107
30108 #[test]
30109 fn swap_chart_js_no_head_returns_unchanged() {
30110 let html = "<body>no head here</body>";
30111 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
30112 }
30113
30114 #[test]
30115 fn swap_chart_js_no_script_in_head_unchanged() {
30116 let html = "<html><head><style>.x{}</style></head><body></body></html>";
30117 let result = swap_inline_chart_js_for_static(html.to_string());
30118 assert!(!result.contains("chart-report.js"));
30119 }
30120
30121 #[test]
30124 fn patch_html_nonce_replaces_old_nonce() {
30125 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
30126 let result = patch_html_nonce(html, "new-nonce-456");
30127 assert!(result.contains(r#"nonce="new-nonce-456""#));
30128 assert!(!result.contains("old-nonce-123"));
30129 }
30130
30131 #[test]
30132 fn patch_html_nonce_injects_into_bare_style() {
30133 let html = "<style>body{color:red;}</style>";
30134 let result = patch_html_nonce(html, "fresh-nonce");
30135 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
30136 }
30137
30138 #[test]
30139 fn patch_html_nonce_injects_into_bare_script() {
30140 let html = "<script>console.log(1);</script>";
30141 let result = patch_html_nonce(html, "abc");
30142 assert!(result.contains(r#"<script nonce="abc">"#));
30143 }
30144
30145 #[test]
30148 fn is_html_report_file_result_html_matches() {
30149 let dir = tempfile::tempdir().unwrap();
30150 let path = dir.path().join("result_20240101.html");
30151 std::fs::write(&path, b"<html></html>").unwrap();
30152 assert!(is_html_report_file(&path));
30153 }
30154
30155 #[test]
30156 fn is_html_report_file_report_html_matches() {
30157 let dir = tempfile::tempdir().unwrap();
30158 let path = dir.path().join("report_abc.html");
30159 std::fs::write(&path, b"<html></html>").unwrap();
30160 assert!(is_html_report_file(&path));
30161 }
30162
30163 #[test]
30164 fn is_html_report_file_index_html_does_not_match() {
30165 let dir = tempfile::tempdir().unwrap();
30166 let path = dir.path().join("index.html");
30167 std::fs::write(&path, b"<html></html>").unwrap();
30168 assert!(!is_html_report_file(&path));
30169 }
30170
30171 #[test]
30172 fn is_html_report_file_nonexistent_returns_false() {
30173 assert!(!is_html_report_file(Path::new(
30174 "/nonexistent/result_xyz.html"
30175 )));
30176 }
30177
30178 #[test]
30179 fn find_html_report_in_dir_finds_result_html() {
30180 let dir = tempfile::tempdir().unwrap();
30181 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
30182 assert!(find_html_report_in_dir(dir.path()).is_some());
30183 }
30184
30185 #[test]
30186 fn find_html_report_in_dir_empty_returns_none() {
30187 let dir = tempfile::tempdir().unwrap();
30188 assert!(find_html_report_in_dir(dir.path()).is_none());
30189 }
30190
30191 #[test]
30192 fn find_html_report_in_tree_finds_in_subdir() {
30193 let dir = tempfile::tempdir().unwrap();
30194 let subdir = dir.path().join("run-001");
30195 std::fs::create_dir_all(&subdir).unwrap();
30196 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
30197 assert!(find_html_report_in_tree(dir.path()).is_some());
30198 }
30199
30200 #[test]
30203 fn derive_project_label_with_git_repo_and_ref() {
30204 let label = derive_project_label(
30205 Some("https://github.com/owner/my-repo.git"),
30206 Some("main"),
30207 "/fallback/path",
30208 );
30209 assert!(!label.is_empty(), "label must not be empty");
30210 assert!(
30211 label.contains("my") || label.contains("repo"),
30212 "got: {label}"
30213 );
30214 }
30215
30216 #[test]
30217 fn derive_project_label_fallback_to_path() {
30218 let label = derive_project_label(None, None, "/path/to/myproject");
30219 assert_eq!(label, "myproject");
30220 }
30221
30222 #[test]
30223 fn derive_project_label_empty_git_fields_use_path() {
30224 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
30225 assert_eq!(label, "cool-app");
30226 }
30227
30228 #[test]
30231 fn derive_file_stem_with_commit_appends_sha() {
30232 assert_eq!(
30233 derive_file_stem("myproject", Some("a1b2c3")),
30234 "myproject_a1b2c3"
30235 );
30236 }
30237
30238 #[test]
30239 fn derive_file_stem_without_commit_returns_label() {
30240 assert_eq!(derive_file_stem("myproject", None), "myproject");
30241 }
30242
30243 #[test]
30244 fn derive_file_stem_empty_commit_returns_label() {
30245 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
30246 }
30247
30248 #[test]
30251 fn split_patterns_none_is_empty() {
30252 assert!(split_patterns(None).is_empty());
30253 }
30254
30255 #[test]
30256 fn split_patterns_empty_string_is_empty() {
30257 assert!(split_patterns(Some("")).is_empty());
30258 }
30259
30260 #[test]
30261 fn split_patterns_comma_separated() {
30262 assert_eq!(
30263 split_patterns(Some("foo,bar,baz")),
30264 vec!["foo", "bar", "baz"]
30265 );
30266 }
30267
30268 #[test]
30269 fn split_patterns_newline_separated() {
30270 assert_eq!(
30271 split_patterns(Some("foo\nbar\nbaz")),
30272 vec!["foo", "bar", "baz"]
30273 );
30274 }
30275
30276 #[test]
30277 fn split_patterns_trims_whitespace() {
30278 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
30279 }
30280
30281 #[test]
30284 fn make_git_label_empty_repo_empty_result() {
30285 assert_eq!(make_git_label("", "main"), "");
30286 }
30287
30288 #[test]
30289 fn make_git_label_empty_ref_empty_result() {
30290 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
30291 }
30292
30293 #[test]
30294 fn make_git_label_basic_format() {
30295 assert_eq!(
30296 make_git_label("https://github.com/owner/my-repo.git", "main"),
30297 "my-repo_at_main_sloc"
30298 );
30299 }
30300
30301 #[test]
30302 fn make_git_label_slash_in_ref_replaced() {
30303 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
30304 assert!(
30305 !label.contains('/'),
30306 "slash in ref must be replaced: {label}"
30307 );
30308 }
30309
30310 #[test]
30313 fn format_dir_size_bytes() {
30314 assert_eq!(format_dir_size(500), "500 B");
30315 }
30316
30317 #[test]
30318 fn format_dir_size_kilobytes() {
30319 assert_eq!(format_dir_size(2048), "2 KB");
30320 }
30321
30322 #[test]
30323 fn format_dir_size_megabytes() {
30324 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
30325 }
30326
30327 #[test]
30328 fn format_dir_size_gigabytes() {
30329 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
30330 }
30331
30332 #[test]
30333 fn format_dir_size_zero() {
30334 assert_eq!(format_dir_size(0), "0 B");
30335 }
30336
30337 #[test]
30340 fn civil_from_days_epoch() {
30341 assert_eq!(civil_from_days(0), (1970, 1, 1));
30342 }
30343
30344 #[test]
30345 fn civil_from_days_one_year_later() {
30346 assert_eq!(civil_from_days(365), (1971, 1, 1));
30347 }
30348
30349 #[test]
30350 fn civil_from_days_31_days_is_feb_1_1970() {
30351 assert_eq!(civil_from_days(31), (1970, 2, 1));
30352 }
30353
30354 #[test]
30357 fn format_system_time_unix_epoch_formats_correctly() {
30358 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
30359 }
30360
30361 #[test]
30362 fn format_system_time_31_days_after_epoch() {
30363 let t = UNIX_EPOCH + Duration::from_hours(744);
30364 assert_eq!(format_system_time(t), "1970-02-01 00:00");
30365 }
30366
30367 #[test]
30368 fn format_system_time_before_epoch_returns_dash() {
30369 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
30370 assert_eq!(format_system_time(before), "-");
30371 }
30372 }
30373
30374 #[test]
30377 fn detect_language_name_dot_c() {
30378 assert_eq!(detect_language_name("main.c"), Some("C"));
30379 }
30380
30381 #[test]
30382 fn detect_language_name_dot_h() {
30383 assert_eq!(detect_language_name("defs.h"), Some("C"));
30384 }
30385
30386 #[test]
30387 fn detect_language_name_dot_cpp() {
30388 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
30389 }
30390
30391 #[test]
30392 fn detect_language_name_dot_py() {
30393 assert_eq!(detect_language_name("script.py"), Some("Python"));
30394 }
30395
30396 #[test]
30397 fn detect_language_name_dot_ps1() {
30398 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
30399 }
30400
30401 #[test]
30402 fn detect_language_name_dot_cs() {
30403 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
30404 }
30405
30406 #[test]
30407 fn detect_language_name_dot_sh() {
30408 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
30409 }
30410
30411 #[test]
30412 fn detect_language_name_unknown_txt() {
30413 assert_eq!(detect_language_name("notes.txt"), None);
30414 }
30415
30416 #[test]
30419 fn language_icon_file_c() {
30420 assert_eq!(language_icon_file("C"), Some("c.png"));
30421 }
30422
30423 #[test]
30424 fn language_icon_file_python() {
30425 assert_eq!(language_icon_file("Python"), Some("python.png"));
30426 }
30427
30428 #[test]
30429 fn language_icon_file_dockerfile() {
30430 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
30431 }
30432
30433 #[test]
30434 fn language_icon_file_rust_is_none() {
30435 assert!(language_icon_file("Rust").is_none());
30436 }
30437
30438 #[test]
30439 fn language_icon_file_unknown_is_none() {
30440 assert!(language_icon_file("Fortran").is_none());
30441 }
30442
30443 #[test]
30446 fn language_inline_svg_rust_is_svg() {
30447 let svg = language_inline_svg("Rust").unwrap();
30448 assert!(svg.starts_with("<svg"));
30449 }
30450
30451 #[test]
30452 fn language_inline_svg_typescript_is_some() {
30453 assert!(language_inline_svg("TypeScript").is_some());
30454 }
30455
30456 #[test]
30457 fn language_inline_svg_unknown_is_none() {
30458 assert!(language_inline_svg("Fortran").is_none());
30459 }
30460
30461 #[test]
30464 fn classify_preview_file_c_supported() {
30465 assert!(matches!(
30466 classify_preview_file("main.c"),
30467 PreviewKind::Supported
30468 ));
30469 }
30470
30471 #[test]
30472 fn classify_preview_file_python_supported() {
30473 assert!(matches!(
30474 classify_preview_file("script.py"),
30475 PreviewKind::Supported
30476 ));
30477 }
30478
30479 #[test]
30480 fn classify_preview_file_png_skipped() {
30481 assert!(matches!(
30482 classify_preview_file("image.png"),
30483 PreviewKind::Skipped
30484 ));
30485 }
30486
30487 #[test]
30488 fn classify_preview_file_zip_skipped() {
30489 assert!(matches!(
30490 classify_preview_file("archive.zip"),
30491 PreviewKind::Skipped
30492 ));
30493 }
30494
30495 #[test]
30496 fn classify_preview_file_min_js_skipped() {
30497 assert!(matches!(
30498 classify_preview_file("bundle.min.js"),
30499 PreviewKind::Skipped
30500 ));
30501 }
30502
30503 #[test]
30504 fn classify_preview_file_rs_unsupported() {
30505 assert!(matches!(
30506 classify_preview_file("main.rs"),
30507 PreviewKind::Unsupported
30508 ));
30509 }
30510
30511 #[test]
30514 fn preview_relative_path_strips_root() {
30515 let root = PathBuf::from("/project");
30516 let path = PathBuf::from("/project/src/main.c");
30517 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
30518 }
30519
30520 #[test]
30521 fn preview_relative_path_unrooted_includes_filename() {
30522 let root = PathBuf::from("/other");
30523 let path = PathBuf::from("/project/src/main.c");
30524 let result = preview_relative_path(&root, &path);
30525 assert!(result.contains("main.c"));
30526 }
30527
30528 #[test]
30529 fn preview_relative_path_uses_forward_slashes() {
30530 let root = PathBuf::from("/project");
30531 let path = PathBuf::from("/project/a/b/c.py");
30532 assert!(!preview_relative_path(&root, &path).contains('\\'));
30533 }
30534
30535 #[test]
30538 fn wildcard_match_exact_equal() {
30539 assert!(wildcard_match("foo", "foo"));
30540 }
30541
30542 #[test]
30543 fn wildcard_match_exact_mismatch() {
30544 assert!(!wildcard_match("foo", "bar"));
30545 }
30546
30547 #[test]
30548 fn wildcard_match_star_suffix() {
30549 assert!(wildcard_match("*.rs", "main.rs"));
30550 }
30551
30552 #[test]
30553 fn wildcard_match_star_middle_requires_suffix() {
30554 assert!(!wildcard_match("a*b", "ac"));
30555 }
30556
30557 #[test]
30558 fn wildcard_match_question_mark_single_char() {
30559 assert!(wildcard_match("f?o", "foo"));
30560 }
30561
30562 #[test]
30563 fn wildcard_match_double_star_nested() {
30564 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
30565 }
30566
30567 #[test]
30568 fn wildcard_match_star_directory_entry() {
30569 assert!(wildcard_match("vendor/*", "vendor/crate"));
30570 }
30571
30572 #[test]
30573 fn wildcard_match_no_cross_prefix() {
30574 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
30575 }
30576
30577 #[test]
30580 fn should_skip_empty_relative_is_false() {
30581 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
30582 }
30583
30584 #[test]
30585 fn should_skip_matching_pattern() {
30586 assert!(should_skip_preview_directory(
30587 "vendor",
30588 &["vendor".to_string()]
30589 ));
30590 }
30591
30592 #[test]
30593 fn should_skip_non_matching() {
30594 assert!(!should_skip_preview_directory(
30595 "src",
30596 &["vendor".to_string()]
30597 ));
30598 }
30599
30600 #[test]
30601 fn should_skip_wildcard_prefix() {
30602 assert!(should_skip_preview_directory(
30603 "target/debug",
30604 &["target*".to_string()]
30605 ));
30606 }
30607
30608 #[test]
30611 fn should_include_empty_relative_always_true() {
30612 assert!(should_include_preview_file("", &[], &[]));
30613 }
30614
30615 #[test]
30616 fn should_include_no_patterns_includes_all() {
30617 assert!(should_include_preview_file("src/main.c", &[], &[]));
30618 }
30619
30620 #[test]
30621 fn should_include_excluded_by_pattern() {
30622 assert!(!should_include_preview_file(
30623 "vendor/lib.c",
30624 &[],
30625 &["vendor/*".to_string()]
30626 ));
30627 }
30628
30629 #[test]
30630 fn should_include_include_pattern_filters() {
30631 assert!(!should_include_preview_file(
30632 "tests/test_foo.c",
30633 &["src/*".to_string()],
30634 &[]
30635 ));
30636 }
30637
30638 #[test]
30641 fn escape_html_ampersand() {
30642 assert_eq!(escape_html("a&b"), "a&b");
30643 }
30644
30645 #[test]
30646 fn escape_html_angle_brackets() {
30647 assert_eq!(escape_html("<br>"), "<br>");
30648 }
30649
30650 #[test]
30651 fn escape_html_double_quote() {
30652 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
30653 }
30654
30655 #[test]
30656 fn escape_html_single_quote() {
30657 assert_eq!(escape_html("it's"), "it's");
30658 }
30659
30660 #[test]
30661 fn escape_html_plain_text_unchanged() {
30662 assert_eq!(escape_html("hello world"), "hello world");
30663 }
30664
30665 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
30668 sloc_core::ScanComparison {
30669 summary: sloc_core::SummaryDelta {
30670 baseline_run_id: "base".to_string(),
30671 current_run_id: "curr".to_string(),
30672 baseline_timestamp: chrono::Utc::now(),
30673 current_timestamp: chrono::Utc::now(),
30674 baseline_files: 4,
30675 current_files: 4,
30676 files_analyzed_delta: 0,
30677 baseline_code: 330,
30678 current_code: 400,
30679 code_lines_delta: 70,
30680 baseline_comments: 0,
30681 current_comments: 0,
30682 comment_lines_delta: 0,
30683 blank_lines_delta: 0,
30684 total_lines_delta: 70,
30685 coverage_lines_hit_delta: None,
30686 coverage_line_pct_delta: None,
30687 baseline_coverage_line_pct: None,
30688 current_coverage_line_pct: None,
30689 },
30690 file_deltas: vec![
30691 sloc_core::FileDelta {
30692 relative_path: "added.rs".to_string(),
30693 language: Some("Rust".to_string()),
30694 status: FileChangeStatus::Added,
30695 baseline_code: 0,
30696 current_code: 100,
30697 code_delta: 100,
30698 baseline_comment: 0,
30699 current_comment: 0,
30700 comment_delta: 0,
30701 baseline_blank: 0,
30702 current_blank: 0,
30703 blank_delta: 0,
30704 total_delta: 100,
30705 },
30706 sloc_core::FileDelta {
30707 relative_path: "removed.rs".to_string(),
30708 language: Some("Rust".to_string()),
30709 status: FileChangeStatus::Removed,
30710 baseline_code: 50,
30711 current_code: 0,
30712 code_delta: -50,
30713 baseline_comment: 0,
30714 current_comment: 0,
30715 comment_delta: 0,
30716 baseline_blank: 0,
30717 current_blank: 0,
30718 blank_delta: 0,
30719 total_delta: -50,
30720 },
30721 sloc_core::FileDelta {
30722 relative_path: "modified.rs".to_string(),
30723 language: Some("Rust".to_string()),
30724 status: FileChangeStatus::Modified,
30725 baseline_code: 80,
30726 current_code: 100,
30727 code_delta: 20,
30728 baseline_comment: 0,
30729 current_comment: 0,
30730 comment_delta: 0,
30731 baseline_blank: 0,
30732 current_blank: 0,
30733 blank_delta: 0,
30734 total_delta: 20,
30735 },
30736 sloc_core::FileDelta {
30737 relative_path: "unchanged.rs".to_string(),
30738 language: Some("Rust".to_string()),
30739 status: FileChangeStatus::Unchanged,
30740 baseline_code: 200,
30741 current_code: 200,
30742 code_delta: 0,
30743 baseline_comment: 0,
30744 current_comment: 0,
30745 comment_delta: 0,
30746 baseline_blank: 0,
30747 current_blank: 0,
30748 blank_delta: 0,
30749 total_delta: 0,
30750 },
30751 ],
30752 files_added: 1,
30753 files_removed: 1,
30754 files_modified: 1,
30755 files_unchanged: 1,
30756 }
30757 }
30758
30759 #[test]
30760 fn sum_added_counts_added_and_positive_modified() {
30761 let cmp = make_mixed_scan_comparison();
30762 assert_eq!(sum_added_code_lines(&cmp), 120);
30763 }
30764
30765 #[test]
30766 fn sum_removed_counts_removed_baseline() {
30767 let cmp = make_mixed_scan_comparison();
30768 assert_eq!(sum_removed_code_lines(&cmp), 50);
30769 }
30770
30771 #[test]
30772 fn sum_unmodified_counts_unchanged_files() {
30773 let cmp = make_mixed_scan_comparison();
30774 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
30775 }
30776
30777 #[test]
30780 fn detect_coverage_tool_rust_project() {
30781 let dir = tempfile::tempdir().unwrap();
30782 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
30783 let (tool, cmd) = detect_coverage_tool(dir.path());
30784 assert_eq!(tool, Some("cargo-llvm-cov"));
30785 assert!(cmd.is_some());
30786 }
30787
30788 #[test]
30789 fn detect_coverage_tool_java_gradle() {
30790 let dir = tempfile::tempdir().unwrap();
30791 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
30792 let (tool, _) = detect_coverage_tool(dir.path());
30793 assert_eq!(tool, Some("jacoco"));
30794 }
30795
30796 #[test]
30797 fn detect_coverage_tool_python_pyproject() {
30798 let dir = tempfile::tempdir().unwrap();
30799 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
30800 let (tool, _) = detect_coverage_tool(dir.path());
30801 assert_eq!(tool, Some("pytest-cov"));
30802 }
30803
30804 #[test]
30805 fn detect_coverage_tool_unknown_project() {
30806 let dir = tempfile::tempdir().unwrap();
30807 let (tool, cmd) = detect_coverage_tool(dir.path());
30808 assert!(tool.is_none() && cmd.is_none());
30809 }
30810
30811 #[test]
30814 fn sanitize_path_str_unc_drive_stripped() {
30815 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
30816 }
30817
30818 #[test]
30819 fn sanitize_path_str_unc_network_stripped() {
30820 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
30821 }
30822
30823 #[test]
30824 fn sanitize_path_str_plain_path_unchanged() {
30825 assert_eq!(
30826 sanitize_path_str("/home/user/project"),
30827 "/home/user/project"
30828 );
30829 }
30830
30831 #[test]
30832 fn display_path_plain_linux_unchanged() {
30833 assert_eq!(
30834 display_path(Path::new("/home/user/project")),
30835 "/home/user/project"
30836 );
30837 }
30838
30839 #[test]
30840 fn display_path_unc_drive_stripped() {
30841 let result = display_path(Path::new(r"\\?\C:\Users\user"));
30842 assert_eq!(result, r"C:\Users\user");
30843 }
30844
30845 #[test]
30846 fn display_path_unc_network_stripped() {
30847 let result = display_path(Path::new(r"\\?\UNC\server\share"));
30848 assert_eq!(result, r"\\server\share");
30849 }
30850}
30851
30852#[cfg(test)]
30853mod coverage_boost_unit_tests {
30854 use super::*;
30855 use std::path::{Path, PathBuf};
30856
30857 #[tokio::test]
30861 async fn runtime_security_config_scenarios() {
30862 std::env::remove_var("SLOC_API_KEYS");
30863 std::env::remove_var("SLOC_API_KEY");
30864 std::env::remove_var("SLOC_TLS_CERT");
30865 std::env::remove_var("SLOC_TLS_KEY");
30866 std::env::remove_var("SLOC_TRUST_PROXY");
30867 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30868 let cfg = load_runtime_security_config(false);
30869 assert!(cfg.api_keys.is_empty());
30870 assert!(!cfg.tls_enabled);
30871 assert!(!cfg.trust_proxy);
30872
30873 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
30874 std::env::set_var("SLOC_TRUST_PROXY", "1");
30875 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
30876 std::env::set_var("SLOC_RATE_LIMIT", "250");
30877 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
30878 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
30879 let cfg = load_runtime_security_config(true);
30880 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
30881 assert!(cfg.trust_proxy);
30882 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
30883 std::env::remove_var("SLOC_API_KEYS");
30884 std::env::remove_var("SLOC_TRUST_PROXY");
30885 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
30886 std::env::remove_var("SLOC_RATE_LIMIT");
30887 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
30888 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
30889 }
30890
30891 #[test]
30892 fn cors_layer_builds_both_modes() {
30893 let _ = build_cors_layer(true);
30894 let _ = build_cors_layer(false);
30895 }
30896
30897 #[test]
30898 fn primary_lan_ip_callable() {
30899 let _ = primary_lan_ip();
30901 }
30902
30903 #[test]
30904 fn safe_redirect_allows_relative_rejects_absolute() {
30905 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
30906 assert_eq!(safe_redirect("https://evil.example/x"), "/");
30907 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
30908 assert_eq!(default_redirect(), "/view-reports");
30909 }
30910
30911 #[test]
30912 fn tarball_size_caps_env_override() {
30913 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
30914 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
30915 let (c, d) = parse_tarball_size_caps();
30916 assert_eq!(c, 1024 * 1024);
30917 assert_eq!(d, 2 * 1024 * 1024);
30918 std::env::remove_var("SLOC_MAX_TARBALL_MB");
30919 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
30920 let (c2, _) = parse_tarball_size_caps();
30921 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
30922 }
30923
30924 #[test]
30925 fn upload_path_helpers() {
30926 let base = upload_base_dir();
30927 let staged = upload_staging_path("abc123");
30928 assert!(staged.starts_with(&base));
30929 assert!(
30930 is_upload_tmp_path(&staged),
30931 "staging path is an upload tmp path"
30932 );
30933 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
30934 }
30935
30936 #[test]
30937 fn git_clones_dir_env_override() {
30938 std::env::remove_var("SLOC_GIT_CLONES_DIR");
30939 let def = resolve_git_clones_dir(Path::new("/out"));
30940 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
30941 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
30942 assert_eq!(
30943 resolve_git_clones_dir(Path::new("/out")),
30944 PathBuf::from("/custom/clones")
30945 );
30946 std::env::remove_var("SLOC_GIT_CLONES_DIR");
30947 }
30948
30949 #[test]
30950 fn html_report_file_detection() {
30951 let dir = std::env::temp_dir().join("sloc_html_detect");
30952 let _ = std::fs::create_dir_all(&dir);
30953 let good = dir.join("report_x.html");
30954 std::fs::write(&good, "<html></html>").unwrap();
30955 let bad = dir.join("notes.txt");
30956 std::fs::write(&bad, "x").unwrap();
30957 assert!(is_html_report_file(&good));
30958 assert!(!is_html_report_file(&bad));
30959 assert!(find_html_report_in_dir(&dir).is_some());
30960 let _ = std::fs::remove_dir_all(&dir);
30961 }
30962
30963 #[test]
30964 fn multi_delta_class_and_format() {
30965 assert_eq!(multi_delta_class(5), "pos");
30966 assert_eq!(multi_delta_class(-5), "neg");
30967 assert_eq!(multi_delta_class(0), "zero");
30968 assert_eq!(multi_fmt_delta(3), "+3");
30969 assert_eq!(multi_fmt_delta(-3), "-3");
30970 assert_eq!(multi_fmt_delta(0), "0");
30971 }
30972
30973 #[test]
30974 fn git_clone_dest_sanitizes() {
30975 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
30976 assert!(dest.starts_with("/clones"));
30977 let name = dest.file_name().unwrap().to_str().unwrap();
30978 assert!(name
30979 .chars()
30980 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
30981 }
30982}
30983
30984#[cfg(test)]
30985mod tests_private {
30986 use super::*;
30987 use std::io::Read;
30988
30989 #[test]
30990 fn size_limit_reader_zero_remaining_returns_error() {
30991 let data = b"hello world";
30992 let mut reader = SizeLimitReader {
30993 inner: &data[..],
30994 remaining: 0,
30995 };
30996 let mut buf = [0u8; 4];
30997 assert!(reader.read(&mut buf).is_err());
30998 }
30999
31000 #[test]
31001 fn size_limit_reader_counts_bytes() {
31002 let data = b"hello world";
31003 let mut reader = SizeLimitReader {
31004 inner: &data[..],
31005 remaining: 5,
31006 };
31007 let mut buf = [0u8; 4];
31008 let n = reader.read(&mut buf).unwrap();
31009 assert_eq!(n, 4);
31010 assert_eq!(reader.remaining, 1);
31011 }
31012
31013 #[test]
31014 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
31015 let uuid = "12345678-1234-1234-1234-123456789012";
31016 let (id, path) = resolve_or_create_staging(Some(uuid));
31017 assert_eq!(id, uuid);
31018 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
31019 }
31020
31021 #[test]
31022 fn resolve_or_create_staging_with_none_creates_new() {
31023 let (id1, _) = resolve_or_create_staging(None);
31024 let (id2, _) = resolve_or_create_staging(None);
31025 assert_ne!(id1, id2);
31026 }
31027
31028 #[test]
31029 fn resolve_or_create_staging_with_path_separator_creates_new() {
31030 let (id, _) = resolve_or_create_staging(Some("has/slash"));
31032 assert_ne!(id, "has/slash");
31033 }
31034
31035 #[test]
31036 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
31037 use std::net::IpAddr;
31038 use std::str::FromStr;
31039 let limiter = IpRateLimiter::new(
31040 Duration::from_secs(60),
31041 100,
31042 5,
31043 Duration::from_secs(300),
31044 );
31045 let ip = IpAddr::from_str("192.168.1.1").unwrap();
31046 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
31047 }
31048
31049 #[test]
31050 fn is_auth_locked_out_expired_entry_removed() {
31051 use std::net::IpAddr;
31052 use std::str::FromStr;
31053 let limiter = IpRateLimiter::new(
31054 Duration::from_secs(60),
31055 100,
31056 1, Duration::from_millis(1),
31058 );
31059 let ip = IpAddr::from_str("192.168.1.2").unwrap();
31060 limiter.record_auth_failure(ip);
31061 std::thread::sleep(Duration::from_millis(10));
31063 assert!(!limiter.is_auth_locked_out(ip));
31065 }
31066
31067 #[test]
31068 fn is_auth_locked_out_within_window_returns_true() {
31069 use std::net::IpAddr;
31070 use std::str::FromStr;
31071 let limiter = IpRateLimiter::new(
31072 Duration::from_secs(60),
31073 100,
31074 2, Duration::from_secs(3600),
31076 );
31077 let ip = IpAddr::from_str("192.168.1.3").unwrap();
31078 limiter.record_auth_failure(ip);
31079 limiter.record_auth_failure(ip);
31080 assert!(limiter.is_auth_locked_out(ip));
31081 }
31082}